Skip to main content

math_core_renderer_internal/
ast.rs

1use alloc::borrow::Cow;
2use alloc::string::String;
3use core::fmt::Write;
4use core::num::NonZeroU16;
5
6use bitflags::bitflags;
7use kstring::KString;
8use percent_encoding::percent_encode;
9#[cfg(feature = "serde")]
10use serde::{Deserialize, Serialize};
11
12use crate::FxHashMap;
13
14use crate::attribute::RowAttrs;
15use crate::escaping::{EscapeHtml, FRAGMENT_SAFE};
16use crate::fmt::new_line_and_indent;
17use crate::itoa::append_u8_as_hex;
18use crate::length::{Length, LengthSet, LengthUnit, LengthValue};
19use crate::symbol::MathMLOperator;
20use crate::table::{
21    Alignment, ArraySpec, BORDER_TOP_DASHED, BORDER_TOP_SOLID, ColumnGenerator, LineType,
22    RIGHT_ALIGN, RowLabelInfo,
23};
24use crate::{
25    attribute::{
26        FracAttr, HtmlTextSize, HtmlTextStyle, LetterAttr, MathSpacing, Notation, OpAttrs, Size,
27        Style,
28    },
29    super_char::SuperChar,
30};
31
32/// Stores the contents of [`Node::AHref`].
33/// Needs to be a separate `struct` to keep [`Node`]
34/// 4 words in size
35#[derive(Clone, Copy, Debug)]
36#[cfg_attr(feature = "serde", derive(Serialize))]
37pub struct AHref<'arena> {
38    pub href: &'arena str,
39    pub text: &'arena str,
40}
41
42/// A single sub/sup pair in [`Node::Multiscripts`].
43#[derive(Clone, Copy, Debug)]
44#[cfg_attr(feature = "serde", derive(Serialize))]
45pub struct MultiscriptPair<'arena> {
46    pub sub: &'arena Node<'arena>,
47    pub sup: &'arena Node<'arena>,
48}
49
50/// AST node
51#[derive(Debug)]
52#[cfg_attr(feature = "serde", derive(Serialize))]
53pub enum Node<'arena> {
54    /// `<mn>...</mn>`
55    Number(&'arena str),
56    /// `<mi>...</mi>` for a [`SuperChar`].
57    IdentifierChar(SuperChar, LetterAttr),
58    /// `<mi>...</mi>` for a string.
59    IdentifierStr(&'arena str),
60    /// `<mo>...</mo>` for a single character.
61    Operator {
62        op: MathMLOperator,
63        attrs: OpAttrs,
64        size: Option<Size>,
65        left: Option<MathSpacing>,
66        right: Option<MathSpacing>,
67    },
68    /// `<mo>...</mo>` for a string.
69    PseudoOp {
70        attrs: OpAttrs,
71        left: Option<MathSpacing>,
72        right: Option<MathSpacing>,
73        name: &'arena str,
74    },
75    /// `<mspace width="..."/>`
76    Space(Length),
77    /// `<msub>...</msub>`
78    Sub {
79        target: &'arena Node<'arena>,
80        symbol: &'arena Node<'arena>,
81    },
82    /// `<msup>...</msup>`
83    Sup {
84        target: &'arena Node<'arena>,
85        symbol: &'arena Node<'arena>,
86    },
87    /// `<msubsup>...</msubsup>`
88    SubSup {
89        target: &'arena Node<'arena>,
90        sub: &'arena Node<'arena>,
91        sup: &'arena Node<'arena>,
92    },
93    /// `<mover accent="true">...</mover>`
94    OverAccent(MathMLOperator, OpAttrs, &'arena Node<'arena>),
95    /// `<munder accentunder="true">...</munder>`
96    UnderAccent(MathMLOperator, OpAttrs, &'arena Node<'arena>),
97    /// `<mover>...</mover>`
98    Over {
99        symbol: &'arena Node<'arena>,
100        target: &'arena Node<'arena>,
101    },
102    /// `<munder>...</munder>`
103    Under {
104        symbol: &'arena Node<'arena>,
105        target: &'arena Node<'arena>,
106    },
107    /// `<munderover>...</munderover>`
108    UnderOver {
109        target: &'arena Node<'arena>,
110        under: &'arena Node<'arena>,
111        over: &'arena Node<'arena>,
112    },
113    /// `<msqrt>...</msqrt>`
114    Sqrt(&'arena Node<'arena>),
115    /// `<mroot>...</mroot>`
116    Root(&'arena Node<'arena>, &'arena Node<'arena>),
117    /// `<mfrac>...</mfrac>`
118    Frac {
119        /// Numerator
120        num: &'arena Node<'arena>,
121        /// Denominator
122        denom: &'arena Node<'arena>,
123        /// Line thickness
124        lt_value: LengthValue,
125        lt_unit: LengthUnit,
126        attr: Option<FracAttr>,
127    },
128    /// `<mrow>...</mrow>`
129    Row {
130        nodes: &'arena [&'arena Node<'arena>],
131        attrs: RowAttrs,
132    },
133    /// `<mpadded>...</mpadded>`
134    Padded {
135        node: &'arena Node<'arena>,
136        width_0: bool,
137        height_0: bool,
138        left: Option<MathSpacing>,
139        right: Option<MathSpacing>,
140        voffset: Option<&'arena LengthSet>,
141    },
142    /// `<mphantom>...</mphantom>`
143    Phantom { node: &'arena Node<'arena> },
144    /// `<mtext>...</mtext>`.
145    /// The `str` gets HTML-escaped.
146    Text {
147        text_style: Option<HtmlTextStyle>,
148        text_size: Option<HtmlTextSize>,
149        text: &'arena str,
150    },
151    /// `<mtext><a href="...">...</a></mtext>`.
152    /// The link and text get HTML-escaped.
153    AHref(&'arena AHref<'arena>),
154    /// `<mtext><a href="...">...</a></mtext>`.
155    /// The link and text get HTML-escaped.
156    EqRef(&'arena str),
157    /// `<mtable>...</mtable>` for matrices and similar constructs
158    Table {
159        align: Alignment,
160        style: Option<Style>,
161        /// A line above the first row, from a `\hline`/`\hdashline` at the start of the
162        /// environment.
163        border_top: Option<LineType>,
164        content: &'arena [&'arena Node<'arena>],
165    },
166    /// `<mtable>...</mtable>` for equation arrays like the `align` environment
167    EquationArray {
168        align: Alignment,
169        last_row_info: Option<&'arena RowLabelInfo<'arena>>,
170        content: &'arena [&'arena Node<'arena>],
171    },
172    /// `<mtable>...</mtable>` for the `multline` environment
173    MultLine {
174        num_rows: NonZeroU16,
175        last_row_info: Option<&'arena RowLabelInfo<'arena>>,
176        content: &'arena [&'arena Node<'arena>],
177    },
178    /// `<mtable>...</mtable>` for arrays
179    Array {
180        style: Option<Style>,
181        array_spec: &'arena ArraySpec<'arena>,
182        content: &'arena [&'arena Node<'arena>],
183    },
184    /// `<mtd>...</mtd>`
185    ColumnSeparator,
186    /// `<mtr>...</mtr>`
187    RowSeparator {
188        label_info: Option<&'arena RowLabelInfo<'arena>>,
189        border_top: Option<LineType>,
190    },
191    /// `<menclose>...</menclose>`
192    Enclose {
193        content: &'arena Node<'arena>,
194        notation: Notation,
195    },
196    /// `<mmultiscripts>...</mmultiscripts>`
197    /// Double pointer indirection is to keep `Node`'s size down.
198    /// Ideally we would use some sort of thinslice type
199    Multiscripts {
200        base: &'arena Node<'arena>,
201        pre: &'arena &'arena [MultiscriptPair<'arena>],
202        post: &'arena &'arena [MultiscriptPair<'arena>],
203    },
204    /// This node is used for displaying unknown commands.
205    /// It's `<merror>` with a custom CSS class
206    /// to override the default ugly yellow background
207    UnknownCommand(&'arena str),
208}
209
210#[cfg(target_arch = "wasm32")]
211static_assertions::assert_eq_size!(Node<'_>, [usize; 4]);
212
213macro_rules! writeln_indent {
214    ($self:ident, $indent:expr, $($tail:tt)+) => {
215        new_line_and_indent(&mut $self.s, $indent, $self.indentation);
216        write!($self.s, $($tail)+)?
217    };
218}
219
220impl Node<'_> {
221    pub const EMPTY_ROW: Self = Self::Row {
222        nodes: &[],
223        attrs: RowAttrs::DEFAULT,
224    };
225}
226
227#[derive(Debug, Clone)]
228#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
229#[cfg_attr(feature = "serde", serde(default, rename_all = "kebab-case"))]
230pub struct CssClassNames {
231    /// The CSS class name to use for unknown commands, if `ignore_unknown_commands` is `true`.
232    pub unknown_command: Cow<'static, str>,
233}
234
235impl Default for CssClassNames {
236    fn default() -> Self {
237        Self {
238            unknown_command: "math-core-unknown-cmd".into(),
239        }
240    }
241}
242
243#[derive(Debug, Clone, Copy)]
244#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
245#[cfg_attr(feature = "serde", serde(untagged))]
246pub enum Indentation {
247    Keyword(IndentKeyword),
248    Spaces(usize),
249}
250
251impl Default for Indentation {
252    fn default() -> Self {
253        Indentation::Spaces(4)
254    }
255}
256
257impl Indentation {
258    pub fn tab() -> Self {
259        Indentation::Keyword(IndentKeyword::Tab)
260    }
261}
262
263#[derive(Debug, Clone, Copy, Default)]
264#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
265#[cfg_attr(feature = "serde", serde(rename_all = "kebab-case"))]
266#[non_exhaustive]
267pub enum IndentKeyword {
268    #[default]
269    Tab,
270}
271
272#[derive(Debug)]
273pub struct Emitter<'state> {
274    s: String,
275    label_map: &'state FxHashMap<KString, KString>,
276    css_classes: &'state CssClassNames,
277    indentation: Indentation,
278    warnings: Warnings,
279    id_prefix: &'state str,
280}
281
282impl<'state> Emitter<'state> {
283    pub fn new(
284        s: String,
285        label_map: &'state FxHashMap<KString, KString>,
286        css_classes: &'state CssClassNames,
287        indentation: Indentation,
288        id_prefix: &'state str,
289    ) -> Self {
290        Self {
291            s,
292            label_map,
293            css_classes,
294            indentation,
295            warnings: Warnings::new(),
296            id_prefix,
297        }
298    }
299
300    pub fn emit(&mut self, node: &Node<'_>, base_indent: usize) -> core::fmt::Result {
301        // Compute the indentation for the children of the node.
302        let child_indent = if base_indent > 0 {
303            base_indent.saturating_add(1)
304        } else {
305            0
306        };
307
308        // Get the base indentation out of the way.
309        new_line_and_indent(&mut self.s, base_indent, self.indentation);
310
311        match *node {
312            Node::Number(number) => {
313                write!(self.s, "<mn>{number}</mn>")?;
314            }
315            Node::IdentifierChar(letter, attr) => {
316                let is_upright = matches!(attr, LetterAttr::ForcedUpright);
317                // Only set "mathvariant" if we are not transforming the letter.
318                let write_mrow = if is_upright {
319                    write!(self.s, "<mrow><mspace/><mi mathvariant=\"normal\">")?;
320                    true
321                } else if letter.try_as_char().is_none() {
322                    // check if multi-char
323                    write!(self.s, "<mrow><mspace/><mi>")?;
324                    true
325                } else {
326                    write!(self.s, "<mi>")?;
327                    false
328                };
329                write!(self.s, "{letter}</mi>")?;
330                if write_mrow {
331                    write!(self.s, "</mrow>")?;
332                }
333            }
334            Node::Operator {
335                op,
336                attrs,
337                left,
338                right,
339                size,
340            } => {
341                emit_operator_attributes(&mut self.s, attrs, left, right)?;
342                if let Some(size) = size {
343                    write!(
344                        self.s,
345                        " minsize=\"{}\" maxsize=\"{}\"",
346                        <&str>::from(size),
347                        <&str>::from(size),
348                    )?;
349                }
350                write!(self.s, ">{op}</mo>")?;
351            }
352            Node::PseudoOp {
353                attrs,
354                left,
355                right,
356                name,
357            } => {
358                emit_operator_attributes(&mut self.s, attrs, left, right)?;
359                write!(self.s, ">{name}</mo>")?;
360            }
361            Node::IdentifierStr(letters) => {
362                // The "<mrow>" with "<mspace/>" is needed to prevent Firefox from adding
363                // extra space around multi-letter identifiers.
364                debug_assert!(
365                    letters.chars().count() > 1,
366                    "single-letter IdentifierStr should be IdentifierChar"
367                );
368                write!(
369                    self.s,
370                    "<mrow><mspace/><mi>{}</mi></mrow>",
371                    EscapeHtml(letters)
372                )?;
373            }
374            Node::Text {
375                text_style,
376                text_size,
377                text: letters,
378            } => {
379                write!(self.s, "<mtext")?;
380                if let Some(size) = text_size {
381                    write!(self.s, " style=\"font-size:{}\"", <&str>::from(size))?;
382                }
383                let (open, close) = match text_style {
384                    None => ("", ""),
385                    Some(HtmlTextStyle::Bold) => ("<b>", "</b>"),
386                    Some(HtmlTextStyle::Italic) => ("<i>", "</i>"),
387                    Some(HtmlTextStyle::BoldItalic) => ("<b><i>", "</i></b>"),
388                    Some(HtmlTextStyle::Emphasis) => ("<em>", "</em>"),
389                    Some(HtmlTextStyle::Typewriter) => ("<code>", "</code>"),
390                    Some(HtmlTextStyle::SmallCaps) => {
391                        ("<span style=\"font-variant-caps: small-caps\">", "</span>")
392                    }
393                    Some(HtmlTextStyle::SansSerif) => {
394                        ("<span class=\"math-core-sans-serif-font\">", "</span>")
395                    }
396                    Some(HtmlTextStyle::Serif) => {
397                        ("<span class=\"math-core-serif-font\">", "</span>")
398                    }
399                    Some(HtmlTextStyle::Strikethrough) => ("<s>", "</s>"),
400                    Some(HtmlTextStyle::Underline) => ("<u>", "</u>"),
401                };
402                write!(self.s, ">{open}{}{close}</mtext>", EscapeHtml(letters))?;
403            }
404            Node::Space(space) => {
405                write!(self.s, "<mspace ")?;
406
407                if space.is_negative() {
408                    write!(self.s, "style=\"margin-left:")?;
409                    space.push_to_string(&mut self.s);
410                    write!(self.s, ";")?;
411                } else {
412                    write!(self.s, "width=\"")?;
413                    space.push_to_string(&mut self.s);
414                    // Work-around for a Firefox bug that causes "rem" to not be processed correctly
415                    if matches!(space.unit, LengthUnit::Rem) {
416                        write!(self.s, "\" style=\"width:")?;
417                        space.push_to_string(&mut self.s);
418                    }
419                }
420
421                write!(self.s, "\"/>")?;
422            }
423            // The following nodes have exactly two children.
424            ref node @ (Node::Sub {
425                symbol: second,
426                target: first,
427            }
428            | Node::Sup {
429                symbol: second,
430                target: first,
431            }
432            | Node::Over {
433                symbol: second,
434                target: first,
435            }
436            | Node::Under {
437                symbol: second,
438                target: first,
439            }
440            | Node::Root(second, first)) => {
441                let (open, close) = match node {
442                    Node::Sub { .. } => ("<msub>", "</msub>"),
443                    Node::Sup { .. } => ("<msup>", "</msup>"),
444                    Node::Over { .. } => ("<mover>", "</mover>"),
445                    Node::Under { .. } => ("<munder>", "</munder>"),
446                    Node::Root(_, _) => ("<mroot>", "</mroot>"),
447                    // Compiler is able to infer that this is unreachable.
448                    _ => unreachable!(),
449                };
450                write!(self.s, "{open}")?;
451                self.emit(first, child_indent)?;
452                self.emit(second, child_indent)?;
453                writeln_indent!(self, base_indent, "{close}");
454            }
455            // The following nodes have exactly three children.
456            ref node @ (Node::SubSup {
457                target: first,
458                sub: second,
459                sup: third,
460            }
461            | Node::UnderOver {
462                target: first,
463                under: second,
464                over: third,
465            }) => {
466                let (open, close) = match node {
467                    Node::SubSup { .. } => ("<msubsup>", "</msubsup>"),
468                    Node::UnderOver { .. } => ("<munderover>", "</munderover>"),
469                    // Compiler is able to infer that this is unreachable.
470                    _ => unreachable!(),
471                };
472                write!(self.s, "{open}")?;
473                self.emit(first, child_indent)?;
474                self.emit(second, child_indent)?;
475                self.emit(third, child_indent)?;
476                writeln_indent!(self, base_indent, "{close}");
477            }
478            Node::Multiscripts { base, pre, post } => {
479                write!(self.s, "<mmultiscripts>")?;
480                self.emit(base, child_indent)?;
481                for &MultiscriptPair { sub, sup } in *post {
482                    self.emit(sub, child_indent)?;
483                    self.emit(sup, child_indent)?;
484                }
485                if !pre.is_empty() {
486                    writeln_indent!(self, child_indent, "<mprescripts/>");
487                    for &MultiscriptPair { sub, sup } in *pre {
488                        self.emit(sub, child_indent)?;
489                        self.emit(sup, child_indent)?;
490                    }
491                }
492
493                writeln_indent!(self, base_indent, "</mmultiscripts>");
494            }
495            ref
496            node @ (Node::OverAccent(op, attr, target) | Node::UnderAccent(op, attr, target)) => {
497                let (open, close) = match node {
498                    Node::OverAccent(_, _, _) => ("<mover accent=\"true\">", "</mover>"),
499                    Node::UnderAccent(_, _, _) => ("<munder accentunder=\"true\">", "</munder>"),
500                    // Compiler is able to infer that this is unreachable.
501                    _ => unreachable!(),
502                };
503                write!(self.s, "{open}")?;
504                self.emit(target, child_indent)?;
505                writeln_indent!(self, child_indent, "<mo");
506                attr.write_to(&mut self.s);
507                write!(self.s, ">{op}</mo>")?;
508                writeln_indent!(self, base_indent, "{close}");
509            }
510            Node::Sqrt(content) => {
511                write!(self.s, "<msqrt>")?;
512                self.emit(content, child_indent)?;
513                writeln_indent!(self, base_indent, "</msqrt>");
514            }
515            Node::Frac {
516                num,
517                denom: den,
518                lt_value: line_length,
519                lt_unit: line_unit,
520                attr,
521            } => {
522                write!(self.s, "<mfrac")?;
523                let lt = Length::from_parts(line_length, line_unit);
524                if let Some(lt) = lt {
525                    write!(self.s, " linethickness=\"")?;
526                    lt.push_to_string(&mut self.s);
527                    write!(self.s, "\"")?;
528                }
529                if let Some(style) = attr {
530                    write!(self.s, "{}", <&str>::from(style))?;
531                }
532                write!(self.s, ">")?;
533                self.emit(num, child_indent)?;
534                self.emit(den, child_indent)?;
535                writeln_indent!(self, base_indent, "</mfrac>");
536            }
537            Node::Row {
538                nodes,
539                attrs:
540                    RowAttrs {
541                        color,
542                        style,
543                        math_shift_compact,
544                    },
545            } => {
546                write!(self.s, "<mrow")?;
547
548                if color.is_some() || math_shift_compact {
549                    write!(self.s, " style=\"")?;
550                    if let Some((r, g, b)) = color {
551                        write!(self.s, "color:#")?;
552                        append_u8_as_hex(&mut self.s, r);
553                        append_u8_as_hex(&mut self.s, g);
554                        append_u8_as_hex(&mut self.s, b);
555                        write!(self.s, ";")?;
556                    }
557                    if math_shift_compact {
558                        write!(self.s, "math-shift:compact;")?;
559                    }
560                    write!(self.s, "\"")?;
561                }
562
563                if let Some(style) = style {
564                    write!(self.s, "{}", <&str>::from(style))?;
565                }
566
567                write!(self.s, ">")?;
568
569                if nodes.is_empty() {
570                    write!(self.s, "</mrow>")?;
571                } else {
572                    for node in nodes {
573                        self.emit(node, child_indent)?;
574                    }
575                    writeln_indent!(self, base_indent, "</mrow>");
576                }
577            }
578            Node::Padded {
579                node,
580                width_0,
581                height_0,
582                left,
583                right,
584                voffset,
585            } => {
586                write!(self.s, "<mpadded")?;
587                if let Some(voffset) = voffset {
588                    let mut voffset_iter = voffset.iter();
589                    // I really, really wish `calc()` worked. But I tried it, and it didn't.
590                    // To produce reasonably minimal XML, the last voffset component is
591                    // attached to the "primary" `<mpadded>` element. Any others get put on
592                    // wrapper elements.
593                    if let Some(voffset_length) = voffset_iter.next() {
594                        write!(self.s, r#" voffset=""#)?;
595                        voffset_length.push_to_string(&mut self.s);
596                        write!(self.s, r#"""#)?;
597                    }
598                    for voffset_length in voffset_iter {
599                        write!(self.s, r#"><mpadded voffset=""#)?;
600                        voffset_length.push_to_string(&mut self.s);
601                        write!(self.s, r#"""#)?;
602                    }
603                }
604                if width_0 {
605                    write!(self.s, r#" width="0""#)?;
606                }
607                if height_0 {
608                    write!(self.s, r#" height="0""#)?;
609                }
610                if left.is_some() || right.is_some() {
611                    write!(self.s, " style=\"")?;
612                    if let Some(left) = left {
613                        write!(self.s, "padding-left:{};", <&str>::from(left))?;
614                    }
615                    if let Some(right) = right {
616                        write!(self.s, "padding-right:{};", <&str>::from(right))?;
617                    }
618                    write!(self.s, "\"")?;
619                }
620                write!(self.s, ">")?;
621                self.emit(node, child_indent)?;
622                writeln_indent!(self, base_indent, "</mpadded>");
623                if let Some(voffset) = voffset {
624                    for _ in voffset.iter().skip(1) {
625                        write!(self.s, r#"</mpadded>"#)?;
626                    }
627                }
628            }
629            Node::Phantom { node } => {
630                write!(self.s, "<mphantom>")?;
631                self.emit(node, child_indent)?;
632                writeln_indent!(self, base_indent, "</mphantom>");
633            }
634            Node::Table {
635                content,
636                align,
637                style,
638                border_top,
639            } => {
640                let mtd_opening = ColumnGenerator::new_predefined(align);
641
642                write!(self.s, "<mtable")?;
643                // A leading `\hline`/`\hdashline` becomes a border on the table itself.
644                match border_top {
645                    Some(LineType::Solid) => write!(self.s, " style=\"{BORDER_TOP_SOLID}\"")?,
646                    Some(LineType::Dashed) => write!(self.s, " style=\"{BORDER_TOP_DASHED}\"")?,
647                    None => (),
648                }
649                if let Some(style) = style {
650                    write!(self.s, "{}", <&str>::from(style))?;
651                }
652                write!(self.s, ">")?;
653                self.emit_table(base_indent, child_indent, content, mtd_opening, None, None)?;
654            }
655            ref node @ (Node::EquationArray {
656                last_row_info,
657                content,
658                ..
659            }
660            | Node::MultLine {
661                last_row_info,
662                content,
663                ..
664            }) => {
665                let (mtd_opening, numbering_cols) = match node {
666                    Node::EquationArray { align, .. } => {
667                        (ColumnGenerator::new_predefined(*align), NumberColums::Wide)
668                    }
669                    Node::MultLine { num_rows, .. } => (
670                        ColumnGenerator::new_multline(*num_rows),
671                        NumberColums::Narrow,
672                    ),
673                    _ => unreachable!(),
674                };
675
676                write!(
677                    &mut self.s,
678                    r#"<mtable displaystyle="true" scriptlevel="0" style="width: 100%">"#
679                )?;
680                self.emit_table(
681                    base_indent,
682                    child_indent,
683                    content,
684                    mtd_opening,
685                    Some(numbering_cols),
686                    last_row_info,
687                )?;
688            }
689            Node::Array {
690                style,
691                content,
692                array_spec,
693            } => {
694                let mtd_opening = ColumnGenerator::new_custom(array_spec);
695                write!(self.s, "<mtable")?;
696                // `border_left` (from a leading `|`/`:`) and `border_top` (from a leading
697                // `\hline`/`\hdashline`) both go into a single `style` attribute on the table.
698                if array_spec.border_left.is_some() || array_spec.border_top.is_some() {
699                    write!(self.s, " style=\"")?;
700                    match array_spec.border_left {
701                        Some(LineType::Solid) => {
702                            write!(self.s, "border-left: 0.05em solid currentcolor;")?;
703                        }
704                        Some(LineType::Dashed) => {
705                            write!(self.s, "border-left: 0.05em dashed currentcolor;")?;
706                        }
707                        None => (),
708                    }
709                    match array_spec.border_top {
710                        Some(LineType::Solid) => write!(self.s, "{BORDER_TOP_SOLID}")?,
711                        Some(LineType::Dashed) => write!(self.s, "{BORDER_TOP_DASHED}")?,
712                        None => (),
713                    }
714                    write!(self.s, "\"")?;
715                }
716                if let Some(style) = style {
717                    write!(self.s, "{}", <&str>::from(style))?;
718                }
719                write!(self.s, ">")?;
720                self.emit_table(base_indent, child_indent, content, mtd_opening, None, None)?;
721            }
722            Node::RowSeparator { .. } | Node::ColumnSeparator => {
723                // This should only appear in tables where it is handled in `emit_table`.
724                if cfg!(debug_assertions) {
725                    panic!("ColumnSeparator node should be handled in emit_table");
726                }
727            }
728            Node::Enclose { content, notation } => {
729                write!(self.s, "<menclose notation=\"")?;
730                let mut first = true;
731                if notation.contains(Notation::BOX) {
732                    write!(self.s, "box")?;
733                    first = false;
734                }
735                if notation.contains(Notation::ACTUARIAL) {
736                    if !first {
737                        write!(self.s, " ")?;
738                    }
739                    write!(self.s, "actuarial")?;
740                    first = false;
741                }
742                if notation.contains(Notation::PHASOR_ANGLE) {
743                    if !first {
744                        write!(self.s, " ")?;
745                    }
746                    write!(self.s, "phasorangle")?;
747                    first = false;
748                }
749                if notation.contains(Notation::UP_DIAGONAL) {
750                    if !first {
751                        write!(self.s, " ")?;
752                    }
753                    write!(self.s, "updiagonalstrike")?;
754                    first = false;
755                }
756                if notation.contains(Notation::DOWN_DIAGONAL) {
757                    if !first {
758                        write!(self.s, " ")?;
759                    }
760                    write!(self.s, "downdiagonalstrike")?;
761                }
762                write!(self.s, "\">")?;
763                self.emit(content, child_indent)?;
764                if notation.contains(Notation::PHASOR_ANGLE) {
765                    writeln_indent!(
766                        self,
767                        child_indent,
768                        "<mrow class=\"menclose-phasorangle\"></mrow>"
769                    );
770                }
771                if notation.contains(Notation::UP_DIAGONAL) {
772                    writeln_indent!(
773                        self,
774                        child_indent,
775                        "<mrow class=\"menclose-updiagonalstrike\"></mrow>"
776                    );
777                }
778                if notation.contains(Notation::DOWN_DIAGONAL) {
779                    writeln_indent!(
780                        self,
781                        child_indent,
782                        "<mrow class=\"menclose-downdiagonalstrike\"></mrow>"
783                    );
784                }
785                writeln_indent!(self, base_indent, "</menclose>");
786            }
787            Node::AHref(&AHref { href, text }) => {
788                write!(
789                    self.s,
790                    r#"<mtext><a href="{}">{}</a></mtext>"#,
791                    EscapeHtml(href),
792                    EscapeHtml(text)
793                )?;
794            }
795            Node::EqRef(label) => {
796                let tag: &str = match self.label_map.get(label) {
797                    Some(tag) => tag,
798                    None => {
799                        self.warnings.set_undefined_references();
800                        "??"
801                    }
802                };
803                write!(
804                    self.s,
805                    r##"<mtext><a href="#{id_prefix}{id}">({text})</a></mtext>"##,
806                    id_prefix = self.id_prefix,
807                    id = percent_encode(label.as_bytes(), FRAGMENT_SAFE),
808                    text = EscapeHtml(tag),
809                )?;
810            }
811            Node::UnknownCommand(cmd_name) => {
812                self.warnings.set_unknown_commands();
813                write!(
814                    self.s,
815                    r#"<merror class="{}"><mtext>\{cmd_name}</mtext></merror>"#,
816                    self.css_classes.unknown_command
817                )?;
818            }
819        }
820        Ok(())
821    }
822
823    fn emit_table(
824        &mut self,
825        base_indent: usize,
826        child_indent: usize,
827        content: &[&Node<'_>],
828        mut col_gen: ColumnGenerator,
829        numbering_cols: Option<NumberColums>,
830        last_row_info: Option<&RowLabelInfo>,
831    ) -> Result<(), core::fmt::Error> {
832        let child_indent2 = if base_indent > 0 {
833            child_indent.saturating_add(1)
834        } else {
835            0
836        };
837        let child_indent3 = if base_indent > 0 {
838            child_indent2.saturating_add(1)
839        } else {
840            0
841        };
842        writeln_indent!(self, child_indent, "<mtr>");
843        if let Some(numbering_cols) = numbering_cols {
844            numbering_cols.initial_dummy_column(&mut self.s, child_indent2, self.indentation)?;
845        }
846        col_gen.write_next_mtd(&mut self.s, child_indent2, self.indentation)?;
847        for node in content {
848            match **node {
849                Node::ColumnSeparator => {
850                    writeln_indent!(self, child_indent2, "</mtd>");
851                    col_gen.write_next_mtd(&mut self.s, child_indent2, self.indentation)?;
852                }
853                Node::RowSeparator {
854                    label_info,
855                    border_top,
856                } => {
857                    writeln_indent!(self, child_indent2, "</mtd>");
858                    if let Some(numbering_cols) = numbering_cols {
859                        write_equation_num(
860                            &mut self.s,
861                            child_indent2,
862                            child_indent3,
863                            label_info,
864                            numbering_cols,
865                            self.indentation,
866                            self.id_prefix,
867                        )?;
868                    }
869                    writeln_indent!(self, child_indent, "</mtr>");
870                    writeln_indent!(self, child_indent, "<mtr>");
871                    if let Some(numbering_cols) = numbering_cols {
872                        numbering_cols.initial_dummy_column(
873                            &mut self.s,
874                            child_indent2,
875                            self.indentation,
876                        )?;
877                    }
878                    col_gen.reset_to_new_row();
879                    // A `\hline`/`\hdashline` right after this `\\` becomes the top border of the
880                    // row we're about to open. MathML `<mtr>` borders aren't rendered by all
881                    // browsers (notably Firefox), so it's applied per-cell by the column generator.
882                    col_gen.set_row_border_top(border_top);
883                    col_gen.write_next_mtd(&mut self.s, child_indent2, self.indentation)?;
884                }
885                _ => {
886                    self.emit(node, child_indent3)?;
887                }
888            }
889        }
890        writeln_indent!(self, child_indent2, "</mtd>");
891        if let Some(numbering_cols) = numbering_cols {
892            write_equation_num(
893                &mut self.s,
894                child_indent2,
895                child_indent3,
896                last_row_info,
897                numbering_cols,
898                self.indentation,
899                self.id_prefix,
900            )?;
901        }
902        writeln_indent!(self, child_indent, "</mtr>");
903        writeln_indent!(self, base_indent, "</mtable>");
904        Ok(())
905    }
906
907    #[must_use]
908    pub fn into_string(self) -> String {
909        self.s
910    }
911
912    #[must_use]
913    pub fn warnings(&self) -> Warnings {
914        self.warnings
915    }
916}
917
918fn emit_operator_attributes(
919    s: &mut String,
920    attrs: OpAttrs,
921    left: Option<MathSpacing>,
922    right: Option<MathSpacing>,
923) -> core::fmt::Result {
924    s.push_str("<mo");
925    attrs.write_to(s);
926    match (left, right) {
927        (Some(left), Some(right)) => {
928            write!(
929                s,
930                " lspace=\"{}\" rspace=\"{}\"",
931                <&str>::from(left),
932                <&str>::from(right)
933            )?;
934        }
935        (Some(left), None) => {
936            write!(s, " lspace=\"{}\"", <&str>::from(left))?;
937        }
938        (None, Some(right)) => {
939            write!(s, " rspace=\"{}\"", <&str>::from(right))?;
940        }
941        _ => {}
942    }
943    Ok(())
944}
945
946#[derive(Clone, Copy, Debug, PartialEq, Eq)]
947enum NumberColums {
948    Narrow,
949    Wide,
950}
951
952impl NumberColums {
953    fn dummy_column_opening(
954        self,
955        s: &mut String,
956        child_indent2: usize,
957        indentation: Indentation,
958    ) -> Result<(), core::fmt::Error> {
959        match self {
960            NumberColums::Narrow => {
961                new_line_and_indent(s, child_indent2, indentation);
962                write!(s, r#"<mtd style="width: 7.5%"#)?;
963            }
964            NumberColums::Wide => {
965                new_line_and_indent(s, child_indent2, indentation);
966                write!(s, r#"<mtd style="width: 50%"#)?;
967            }
968        }
969        Ok(())
970    }
971
972    /// Initial dummy column for equation numbering for keeping alignment.
973    #[inline]
974    fn initial_dummy_column(
975        self,
976        s: &mut String,
977        child_indent2: usize,
978        indentation: Indentation,
979    ) -> Result<(), core::fmt::Error> {
980        self.dummy_column_opening(s, child_indent2, indentation)?;
981        write!(s, "\"></mtd>")?;
982        Ok(())
983    }
984}
985
986fn write_equation_num(
987    s: &mut String,
988    child_indent2: usize,
989    child_indent3: usize,
990    label_info: Option<&RowLabelInfo>,
991    numbering_cols: NumberColums,
992    indentation: Indentation,
993    id_prefix: &str,
994) -> Result<(), core::fmt::Error> {
995    numbering_cols.dummy_column_opening(s, child_indent2, indentation)?;
996    if let Some(label_info) = label_info {
997        write!(s, r#";{RIGHT_ALIGN}""#)?;
998        if let Some(link_target) = label_info.link_target {
999            write!(
1000                s,
1001                r#" id="{id_prefix}{id}">"#,
1002                id = percent_encode(link_target.as_bytes(), FRAGMENT_SAFE)
1003            )?;
1004        } else {
1005            write!(s, ">")?;
1006        }
1007        new_line_and_indent(s, child_indent3, indentation);
1008        let tag = EscapeHtml(label_info.tag.text);
1009        if label_info.tag.parenthesized {
1010            write!(s, "<mtext>({tag})</mtext>")?;
1011        } else {
1012            write!(s, "<mtext>{tag}</mtext>")?;
1013        }
1014        new_line_and_indent(s, child_indent2, indentation);
1015        write!(s, "</mtd>")?;
1016    } else {
1017        write!(s, "\"></mtd>")?;
1018    }
1019    Ok(())
1020}
1021
1022#[derive(Debug, Clone, Copy)]
1023pub struct Warnings {
1024    inner: InnerWarnings,
1025}
1026
1027impl Warnings {
1028    fn new() -> Self {
1029        Self {
1030            inner: InnerWarnings::empty(),
1031        }
1032    }
1033    fn set_undefined_references(&mut self) {
1034        self.inner.insert(InnerWarnings::UNDEFINED_REFERENCES);
1035    }
1036    fn set_unknown_commands(&mut self) {
1037        self.inner.insert(InnerWarnings::UNKNOWN_COMMANDS);
1038    }
1039    pub fn has_undefined_references(&self) -> bool {
1040        self.inner.contains(InnerWarnings::UNDEFINED_REFERENCES)
1041    }
1042    pub fn has_unknown_commands(&self) -> bool {
1043        self.inner.contains(InnerWarnings::UNKNOWN_COMMANDS)
1044    }
1045    pub fn has_any(&self) -> bool {
1046        !self.inner.is_empty()
1047    }
1048}
1049
1050bitflags! {
1051    #[repr(transparent)]
1052    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1053    struct InnerWarnings: u8 {
1054        const UNDEFINED_REFERENCES = 1;
1055        const UNKNOWN_COMMANDS = 1 << 1;
1056    }
1057}
1058
1059#[cfg(test)]
1060mod tests {
1061    use super::super::symbol;
1062    use super::super::table::{ColumnAlignment, ColumnSpecEntry, EquationTag};
1063    use super::*;
1064
1065    const WORD: usize = std::mem::size_of::<usize>();
1066
1067    #[test]
1068    fn test_struct_sizes() {
1069        assert!(std::mem::size_of::<Node>() <= 4 * WORD, "size of Node");
1070    }
1071
1072    pub fn render<'a, 'b>(node: &'a Node<'b>) -> String
1073    where
1074        'a: 'b,
1075    {
1076        let output = String::new();
1077        let label_map = FxHashMap::default();
1078        let css_classes = CssClassNames::default();
1079        let mut emitter = Emitter::new(
1080            output,
1081            &label_map,
1082            &css_classes,
1083            Indentation::default(),
1084            "test-id-prefix-",
1085        );
1086        emitter.emit(node, 0).unwrap();
1087        emitter.into_string()
1088    }
1089
1090    #[test]
1091    fn render_number() {
1092        assert_eq!(render(&Node::Number("3.14")), "<mn>3.14</mn>");
1093    }
1094
1095    #[test]
1096    fn render_single_letter_ident() {
1097        assert_eq!(
1098            render(&Node::IdentifierChar('x'.into(), LetterAttr::Default)),
1099            "<mi>x</mi>"
1100        );
1101        assert_eq!(
1102            render(&Node::IdentifierChar('Γ'.into(), LetterAttr::ForcedUpright)),
1103            "<mrow><mspace/><mi mathvariant=\"normal\">Γ</mi></mrow>"
1104        );
1105        assert_eq!(
1106            render(&Node::IdentifierChar('𝑥'.into(), LetterAttr::Default)),
1107            "<mi>𝑥</mi>"
1108        );
1109    }
1110
1111    #[test]
1112    fn render_operator_with_spacing() {
1113        assert_eq!(
1114            render(&Node::Operator {
1115                op: symbol::COLON.as_op(),
1116                attrs: OpAttrs::empty(),
1117                left: Some(MathSpacing::FourMu),
1118                right: Some(MathSpacing::FourMu),
1119                size: None,
1120            }),
1121            "<mo lspace=\"0.2222em\" rspace=\"0.2222em\">:</mo>"
1122        );
1123        assert_eq!(
1124            render(&Node::Operator {
1125                op: symbol::COLON.as_op(),
1126                attrs: OpAttrs::empty(),
1127                left: Some(MathSpacing::FourMu),
1128                right: Some(MathSpacing::Zero),
1129                size: None,
1130            }),
1131            "<mo lspace=\"0.2222em\" rspace=\"0\">:</mo>"
1132        );
1133        assert_eq!(
1134            render(&Node::Operator {
1135                op: symbol::IDENTICAL_TO.as_op(),
1136                attrs: OpAttrs::empty(),
1137                left: Some(MathSpacing::Zero),
1138                right: None,
1139                size: None,
1140            }),
1141            "<mo lspace=\"0\">≡</mo>"
1142        );
1143        assert_eq!(
1144            render(&Node::Operator {
1145                op: symbol::PLUS_SIGN.as_op(),
1146                attrs: OpAttrs::FORM_PREFIX,
1147                left: None,
1148                right: None,
1149                size: None,
1150            }),
1151            "<mo form=\"prefix\">+</mo>"
1152        );
1153        assert_eq!(
1154            render(&Node::Operator {
1155                op: symbol::N_ARY_SUMMATION.as_op(),
1156                attrs: OpAttrs::NO_MOVABLE_LIMITS,
1157                left: None,
1158                right: None,
1159                size: None,
1160            }),
1161            "<mo movablelimits=\"false\">∑</mo>"
1162        );
1163    }
1164
1165    #[test]
1166    fn render_pseudo_operator() {
1167        assert_eq!(
1168            render(&Node::PseudoOp {
1169                attrs: OpAttrs::empty(),
1170                left: Some(MathSpacing::ThreeMu),
1171                right: Some(MathSpacing::ThreeMu),
1172                name: "sin"
1173            }),
1174            "<mo lspace=\"0.1667em\" rspace=\"0.1667em\">sin</mo>"
1175        );
1176    }
1177
1178    #[test]
1179    fn render_collected_letters() {
1180        assert_eq!(
1181            render(&Node::IdentifierStr("sin")),
1182            "<mrow><mspace/><mi>sin</mi></mrow>"
1183        );
1184    }
1185
1186    #[test]
1187    fn render_space() {
1188        assert_eq!(
1189            render(&Node::Space(Length::new(1.0, LengthUnit::Em))),
1190            "<mspace width=\"1em\"/>"
1191        );
1192    }
1193
1194    #[test]
1195    fn render_subscript() {
1196        assert_eq!(
1197            render(&Node::Sub {
1198                target: &Node::IdentifierChar('x'.into(), LetterAttr::Default),
1199                symbol: &Node::Number("2"),
1200            }),
1201            "<msub><mi>x</mi><mn>2</mn></msub>"
1202        );
1203    }
1204
1205    #[test]
1206    fn render_superscript() {
1207        assert_eq!(
1208            render(&Node::Sup {
1209                target: &Node::IdentifierChar('x'.into(), LetterAttr::Default),
1210                symbol: &Node::Number("2"),
1211            }),
1212            "<msup><mi>x</mi><mn>2</mn></msup>"
1213        );
1214    }
1215
1216    #[test]
1217    fn render_sub_sup() {
1218        assert_eq!(
1219            render(&Node::SubSup {
1220                target: &Node::IdentifierChar('x'.into(), LetterAttr::Default),
1221                sub: &Node::Number("1"),
1222                sup: &Node::Number("2"),
1223            }),
1224            "<msubsup><mi>x</mi><mn>1</mn><mn>2</mn></msubsup>"
1225        );
1226    }
1227
1228    #[test]
1229    fn render_over_op() {
1230        assert_eq!(
1231            render(&Node::OverAccent(
1232                symbol::MACRON.as_op(),
1233                OpAttrs::STRETCHY_FALSE,
1234                &Node::IdentifierChar('x'.into(), LetterAttr::Default),
1235            )),
1236            "<mover accent=\"true\"><mi>x</mi><mo stretchy=\"false\">¯</mo></mover>"
1237        );
1238        assert_eq!(
1239            render(&Node::OverAccent(
1240                symbol::OVERLINE.as_op(),
1241                OpAttrs::empty(),
1242                &Node::IdentifierChar('x'.into(), LetterAttr::Default),
1243            )),
1244            "<mover accent=\"true\"><mi>x</mi><mo>‾</mo></mover>"
1245        );
1246    }
1247
1248    #[test]
1249    fn render_under_op() {
1250        assert_eq!(
1251            render(&Node::UnderAccent(
1252                symbol::COMBINING_LOW_LINE.as_op(),
1253                OpAttrs::empty(),
1254                &Node::IdentifierChar('x'.into(), LetterAttr::Default),
1255            )),
1256            "<munder accentunder=\"true\"><mi>x</mi><mo>\u{332}</mo></munder>"
1257        );
1258    }
1259
1260    #[test]
1261    fn render_overset() {
1262        assert_eq!(
1263            render(&Node::Over {
1264                symbol: &Node::Operator {
1265                    op: symbol::EXCLAMATION_MARK.as_op(),
1266                    attrs: OpAttrs::empty(),
1267                    left: None,
1268                    right: None,
1269                    size: None,
1270                },
1271                target: &Node::Operator {
1272                    op: symbol::EQUALS_SIGN.as_op(),
1273                    attrs: OpAttrs::empty(),
1274                    left: None,
1275                    right: None,
1276                    size: None,
1277                },
1278            }),
1279            "<mover><mo>=</mo><mo>!</mo></mover>"
1280        );
1281    }
1282
1283    #[test]
1284    fn render_underset() {
1285        assert_eq!(
1286            render(&Node::Under {
1287                symbol: &Node::IdentifierChar('θ'.into(), LetterAttr::Default),
1288                target: &Node::PseudoOp {
1289                    attrs: OpAttrs::FORCE_MOVABLE_LIMITS,
1290                    left: Some(MathSpacing::ThreeMu),
1291                    right: Some(MathSpacing::ThreeMu),
1292                    name: "min",
1293                },
1294            }),
1295            "<munder><mo movablelimits=\"true\" lspace=\"0.1667em\" rspace=\"0.1667em\">min</mo><mi>θ</mi></munder>"
1296        );
1297    }
1298
1299    #[test]
1300    fn render_under_over() {
1301        assert_eq!(
1302            render(&Node::UnderOver {
1303                target: &Node::IdentifierChar('x'.into(), LetterAttr::Default),
1304                under: &Node::Number("1"),
1305                over: &Node::Number("2"),
1306            }),
1307            "<munderover><mi>x</mi><mn>1</mn><mn>2</mn></munderover>"
1308        );
1309    }
1310
1311    #[test]
1312    fn render_sqrt() {
1313        assert_eq!(
1314            render(&Node::Sqrt(&Node::IdentifierChar(
1315                'x'.into(),
1316                LetterAttr::Default
1317            ))),
1318            "<msqrt><mi>x</mi></msqrt>"
1319        );
1320    }
1321
1322    #[test]
1323    fn render_root() {
1324        assert_eq!(
1325            render(&Node::Root(
1326                &Node::Number("3"),
1327                &Node::IdentifierChar('x'.into(), LetterAttr::Default),
1328            )),
1329            "<mroot><mi>x</mi><mn>3</mn></mroot>"
1330        );
1331    }
1332
1333    #[test]
1334    fn render_frac() {
1335        let num = &Node::Number("1");
1336        let denom = &Node::Number("2");
1337        let (lt_value, lt_unit) = Length::none().into_parts();
1338        assert_eq!(
1339            render(&Node::Frac {
1340                num,
1341                denom,
1342                lt_value,
1343                lt_unit,
1344                attr: None,
1345            }),
1346            "<mfrac><mn>1</mn><mn>2</mn></mfrac>"
1347        );
1348        assert_eq!(
1349            render(&Node::Frac {
1350                num,
1351                denom,
1352                lt_value,
1353                lt_unit,
1354                attr: Some(FracAttr::DisplayStyleTrue),
1355            }),
1356            "<mfrac displaystyle=\"true\"><mn>1</mn><mn>2</mn></mfrac>"
1357        );
1358        assert_eq!(
1359            render(&Node::Frac {
1360                num,
1361                denom,
1362                lt_value,
1363                lt_unit,
1364                attr: Some(FracAttr::DisplayStyleFalse),
1365            }),
1366            "<mfrac displaystyle=\"false\"><mn>1</mn><mn>2</mn></mfrac>"
1367        );
1368        let (lt_value, lt_unit) = Length::new(-1.0, LengthUnit::Rem).into_parts();
1369        assert_eq!(
1370            render(&Node::Frac {
1371                num,
1372                denom,
1373                lt_value,
1374                lt_unit,
1375                attr: None,
1376            }),
1377            "<mfrac linethickness=\"-1rem\"><mn>1</mn><mn>2</mn></mfrac>"
1378        );
1379        assert_eq!(
1380            render(&Node::Frac {
1381                num,
1382                denom,
1383                lt_value: LengthValue(1.0),
1384                lt_unit: LengthUnit::Em,
1385                attr: None,
1386            }),
1387            "<mfrac linethickness=\"1em\"><mn>1</mn><mn>2</mn></mfrac>"
1388        );
1389        assert_eq!(
1390            render(&Node::Frac {
1391                num,
1392                denom,
1393                lt_value: LengthValue(-1.0),
1394                lt_unit: LengthUnit::Ex,
1395                attr: None,
1396            }),
1397            "<mfrac linethickness=\"-1ex\"><mn>1</mn><mn>2</mn></mfrac>"
1398        );
1399        let (lt_value, lt_unit) = Length::new(2.0, LengthUnit::Rem).into_parts();
1400        assert_eq!(
1401            render(&Node::Frac {
1402                num,
1403                denom,
1404                lt_value,
1405                lt_unit,
1406                attr: None,
1407            }),
1408            "<mfrac linethickness=\"2rem\"><mn>1</mn><mn>2</mn></mfrac>"
1409        );
1410        let (lt_value, lt_unit) = Length::zero().into_parts();
1411        assert_eq!(
1412            render(&Node::Frac {
1413                num,
1414                denom,
1415                lt_value,
1416                lt_unit,
1417                attr: Some(FracAttr::DisplayStyleTrue),
1418            }),
1419            "<mfrac linethickness=\"0\" displaystyle=\"true\"><mn>1</mn><mn>2</mn></mfrac>"
1420        );
1421    }
1422
1423    #[test]
1424    fn render_row() {
1425        let nodes = &[
1426            &Node::IdentifierChar('x'.into(), LetterAttr::Default),
1427            &Node::Operator {
1428                op: symbol::EQUALS_SIGN.as_op(),
1429                attrs: OpAttrs::empty(),
1430                left: None,
1431                right: None,
1432                size: None,
1433            },
1434            &Node::Number("1"),
1435        ];
1436
1437        assert_eq!(
1438            render(&Node::Row {
1439                nodes,
1440                attrs: RowAttrs {
1441                    style: Some(Style::Display),
1442                    ..RowAttrs::DEFAULT
1443                }
1444            }),
1445            "<mrow displaystyle=\"true\" scriptlevel=\"0\"><mi>x</mi><mo>=</mo><mn>1</mn></mrow>"
1446        );
1447
1448        assert_eq!(
1449            render(&Node::Row {
1450                nodes,
1451                attrs: RowAttrs {
1452                    color: Some((0, 0, 0)),
1453                    ..RowAttrs::DEFAULT
1454                }
1455            }),
1456            "<mrow style=\"color:#000000;\"><mi>x</mi><mo>=</mo><mn>1</mn></mrow>"
1457        );
1458    }
1459
1460    #[test]
1461    fn render_padded() {
1462        assert_eq!(
1463            render(&Node::Padded {
1464                node: &Node::Number("x"),
1465                width_0: true,
1466                height_0: false,
1467                left: None,
1468                right: Some(MathSpacing::FourMu),
1469                voffset: None,
1470            }),
1471            "<mpadded width=\"0\" style=\"padding-right:0.2222em;\"><mn>x</mn></mpadded>"
1472        );
1473        assert_eq!(
1474            render(&Node::Padded {
1475                node: &Node::Number("x"),
1476                width_0: true,
1477                height_0: false,
1478                left: None,
1479                right: Some(MathSpacing::FourMu),
1480                voffset: Length::from_parts(LengthValue(1.0), LengthUnit::Em)
1481                    .map(LengthSet::from)
1482                    .as_ref(),
1483            }),
1484            "<mpadded voffset=\"1em\" width=\"0\" style=\"padding-right:0.2222em;\"><mn>x</mn></mpadded>"
1485        );
1486        let a = Length::from_parts(LengthValue(1.0), LengthUnit::Em).unwrap();
1487        let b = Length::from_parts(LengthValue(2.0), LengthUnit::Rem).unwrap();
1488        let c = Length::from_parts(LengthValue(3.0), LengthUnit::Ex).unwrap();
1489        let voffset = LengthSet::from(a) + LengthSet::from(b) + LengthSet::from(c);
1490        assert_eq!(
1491            render(&Node::Padded {
1492                node: &Node::Number("x"),
1493                width_0: true,
1494                height_0: false,
1495                left: None,
1496                right: Some(MathSpacing::FourMu),
1497                voffset: Some(&voffset),
1498            }),
1499            "<mpadded voffset=\"2rem\"><mpadded voffset=\"1em\"><mpadded voffset=\"3ex\" width=\"0\" style=\"padding-right:0.2222em;\"><mn>x</mn></mpadded></mpadded></mpadded>"
1500        );
1501    }
1502
1503    #[test]
1504    fn render_phantom() {
1505        assert_eq!(
1506            render(&Node::Phantom {
1507                node: &Node::Number("x")
1508            }),
1509            "<mphantom><mn>x</mn></mphantom>"
1510        );
1511    }
1512
1513    #[test]
1514    fn render_eqref() {
1515        assert_eq!(
1516            render(&Node::EqRef("thing")),
1517            "<mtext><a href=\"#test-id-prefix-thing\">(??)</a></mtext>"
1518        );
1519    }
1520
1521    #[test]
1522    fn render_sized_operator() {
1523        assert_eq!(
1524            render(&Node::Operator {
1525                op: symbol::LEFT_PARENTHESIS.as_op(),
1526                attrs: OpAttrs::empty(),
1527                size: Some(Size::Scale1),
1528                left: None,
1529                right: None,
1530            }),
1531            "<mo minsize=\"1.2em\" maxsize=\"1.2em\">(</mo>"
1532        );
1533        assert_eq!(
1534            render(&Node::Operator {
1535                op: symbol::SOLIDUS.as_op(),
1536                attrs: OpAttrs::STRETCHY_TRUE | OpAttrs::SYMMETRIC_TRUE,
1537                size: Some(Size::Scale3),
1538                left: Some(MathSpacing::Zero),
1539                right: Some(MathSpacing::Zero),
1540            }),
1541            "<mo stretchy=\"true\" symmetric=\"true\" lspace=\"0\" rspace=\"0\" minsize=\"2.047em\" maxsize=\"2.047em\">/</mo>"
1542        );
1543    }
1544
1545    #[test]
1546    fn render_text() {
1547        assert_eq!(
1548            render(&Node::Text {
1549                text_style: None,
1550                text_size: None,
1551                text: "hello"
1552            }),
1553            "<mtext>hello</mtext>"
1554        );
1555    }
1556
1557    #[test]
1558    fn render_table() {
1559        let nodes = [
1560            &Node::Number("1"),
1561            &Node::ColumnSeparator,
1562            &Node::Number("2"),
1563            &Node::RowSeparator {
1564                label_info: None,
1565                border_top: None,
1566            },
1567            &Node::Number("3"),
1568            &Node::ColumnSeparator,
1569            &Node::Number("4"),
1570        ];
1571
1572        assert_eq!(
1573            render(&Node::Table {
1574                content: &nodes,
1575                align: Alignment::Centered,
1576                style: None,
1577                border_top: None,
1578            }),
1579            "<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>"
1580        );
1581    }
1582
1583    #[test]
1584    fn render_equation_array() {
1585        let nodes = [
1586            &Node::Number("1"),
1587            &Node::ColumnSeparator,
1588            &Node::Number("2"),
1589            &Node::RowSeparator {
1590                label_info: Some(&RowLabelInfo {
1591                    tag: EquationTag {
1592                        text: "1",
1593                        parenthesized: true,
1594                    },
1595                    link_target: None,
1596                }),
1597                border_top: None,
1598            },
1599            &Node::Number("3"),
1600            &Node::ColumnSeparator,
1601            &Node::Number("4"),
1602        ];
1603
1604        let info_with_tag = RowLabelInfo {
1605            tag: EquationTag {
1606                text: "2",
1607                parenthesized: true,
1608            },
1609            link_target: None,
1610        };
1611        assert_eq!(
1612            render(&Node::EquationArray {
1613                content: &nodes,
1614                align: Alignment::Centered,
1615                last_row_info: Some(&info_with_tag),
1616            }),
1617            "<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>"
1618        );
1619
1620        assert_eq!(
1621            render(&Node::EquationArray {
1622                content: &nodes,
1623                align: Alignment::Centered,
1624                last_row_info: None,
1625            }),
1626            "<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>"
1627        );
1628    }
1629
1630    #[test]
1631    fn render_equation_array_with_link_target() {
1632        let nodes = [
1633            &Node::Number("1"),
1634            &Node::ColumnSeparator,
1635            &Node::Number("2"),
1636            &Node::RowSeparator {
1637                label_info: Some(&RowLabelInfo {
1638                    tag: EquationTag {
1639                        text: "1",
1640                        parenthesized: true,
1641                    },
1642                    link_target: Some("eq:1"),
1643                }),
1644                border_top: None,
1645            },
1646            &Node::Number("3"),
1647            &Node::ColumnSeparator,
1648            &Node::Number("4"),
1649        ];
1650
1651        let info_with_tag = RowLabelInfo {
1652            tag: EquationTag {
1653                text: "2",
1654                parenthesized: true,
1655            },
1656            link_target: Some("eq:2"),
1657        };
1658        assert_eq!(
1659            render(&Node::EquationArray {
1660                content: &nodes,
1661                align: Alignment::Centered,
1662                last_row_info: Some(&info_with_tag),
1663            }),
1664            "<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;\" id=\"test-id-prefix-eq:1\"><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;\" id=\"test-id-prefix-eq:2\"><mtext>(2)</mtext></mtd></mtr></mtable>"
1665        );
1666
1667        assert_eq!(
1668            render(&Node::EquationArray {
1669                content: &nodes,
1670                align: Alignment::Centered,
1671                last_row_info: None,
1672            }),
1673            "<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;\" id=\"test-id-prefix-eq:1\"><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>"
1674        );
1675    }
1676
1677    #[test]
1678    fn render_array() {
1679        let nodes = [
1680            &Node::Number("1"),
1681            &Node::ColumnSeparator,
1682            &Node::Number("2"),
1683            &Node::RowSeparator {
1684                label_info: None,
1685                border_top: None,
1686            },
1687            &Node::Number("3"),
1688            &Node::ColumnSeparator,
1689            &Node::Number("4"),
1690        ];
1691
1692        assert_eq!(
1693            render(&Node::Array {
1694                style: None,
1695                content: &nodes,
1696                array_spec: &ArraySpec {
1697                    border_left: None,
1698                    border_top: None,
1699                    is_sub: false,
1700                    column_spec: &[
1701                        ColumnSpecEntry::WithContent {
1702                            alignment: ColumnAlignment::LeftJustified,
1703                            border_right: None
1704                        },
1705                        ColumnSpecEntry::WithContent {
1706                            alignment: ColumnAlignment::Centered,
1707                            border_right: None
1708                        },
1709                    ],
1710                },
1711            }),
1712            "<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>"
1713        );
1714    }
1715
1716    #[test]
1717    fn render_array_with_hlines() {
1718        // A leading `\hline` (solid) sets `border_top` on the array spec, and an `\hline`
1719        // after the `\\` sets `border_top` on the corresponding `RowSeparator`.
1720        let nodes = [
1721            &Node::Number("1"),
1722            &Node::ColumnSeparator,
1723            &Node::Number("2"),
1724            &Node::RowSeparator {
1725                label_info: None,
1726                border_top: Some(LineType::Dashed),
1727            },
1728            &Node::Number("3"),
1729            &Node::ColumnSeparator,
1730            &Node::Number("4"),
1731        ];
1732
1733        assert_eq!(
1734            render(&Node::Array {
1735                style: None,
1736                content: &nodes,
1737                array_spec: &ArraySpec {
1738                    border_left: None,
1739                    border_top: Some(LineType::Solid),
1740                    is_sub: false,
1741                    column_spec: &[
1742                        ColumnSpecEntry::WithContent {
1743                            alignment: ColumnAlignment::Centered,
1744                            border_right: None
1745                        },
1746                        ColumnSpecEntry::WithContent {
1747                            alignment: ColumnAlignment::Centered,
1748                            border_right: None
1749                        },
1750                    ],
1751                },
1752            }),
1753            "<mtable style=\"border-top: 0.05em solid currentcolor;\"><mtr><mtd><mn>1</mn></mtd><mtd><mn>2</mn></mtd></mtr><mtr><mtd style=\"border-top: 0.05em dashed currentcolor;\"><mn>3</mn></mtd><mtd style=\"border-top: 0.05em dashed currentcolor;\"><mn>4</mn></mtd></mtr></mtable>"
1754        );
1755    }
1756
1757    #[test]
1758    fn render_table_with_hlines() {
1759        // Same as `render_array_with_hlines`, but for a matrix: the leading `\hline` sets
1760        // `border_top` on the table itself.
1761        let nodes = [
1762            &Node::Number("1"),
1763            &Node::ColumnSeparator,
1764            &Node::Number("2"),
1765            &Node::RowSeparator {
1766                label_info: None,
1767                border_top: Some(LineType::Dashed),
1768            },
1769            &Node::Number("3"),
1770            &Node::ColumnSeparator,
1771            &Node::Number("4"),
1772        ];
1773
1774        assert_eq!(
1775            render(&Node::Table {
1776                content: &nodes,
1777                align: Alignment::Centered,
1778                style: None,
1779                border_top: Some(LineType::Solid),
1780            }),
1781            "<mtable style=\"border-top: 0.05em solid currentcolor;\"><mtr><mtd><mn>1</mn></mtd><mtd><mn>2</mn></mtd></mtr><mtr><mtd style=\"border-top: 0.05em dashed currentcolor;\"><mn>3</mn></mtd><mtd style=\"border-top: 0.05em dashed currentcolor;\"><mn>4</mn></mtd></mtr></mtable>"
1782        );
1783    }
1784
1785    #[test]
1786    fn render_multiscript() {
1787        assert_eq!(
1788            render(&Node::Multiscripts {
1789                base: &Node::IdentifierChar('x'.into(), LetterAttr::Default),
1790                pre: &const {
1791                    &[MultiscriptPair {
1792                        sub: &Node::Number("1"),
1793                        sup: &Node::EMPTY_ROW,
1794                    }]
1795                },
1796                post: &const { &[] },
1797            }),
1798            "<mmultiscripts><mi>x</mi><mprescripts/><mn>1</mn><mrow></mrow></mmultiscripts>"
1799        );
1800    }
1801
1802    #[test]
1803    fn render_text_transform() {
1804        assert_eq!(
1805            render(&Node::IdentifierChar('a'.into(), LetterAttr::ForcedUpright)),
1806            "<mrow><mspace/><mi mathvariant=\"normal\">a</mi></mrow>"
1807        );
1808        assert_eq!(
1809            render(&Node::IdentifierChar('a'.into(), LetterAttr::ForcedUpright)),
1810            "<mrow><mspace/><mi mathvariant=\"normal\">a</mi></mrow>"
1811        );
1812        assert_eq!(
1813            render(&Node::IdentifierStr("abc")),
1814            "<mrow><mspace/><mi>abc</mi></mrow>"
1815        );
1816        assert_eq!(
1817            render(&Node::IdentifierChar('𝐚'.into(), LetterAttr::Default)),
1818            "<mi>𝐚</mi>"
1819        );
1820        assert_eq!(
1821            render(&Node::IdentifierChar('𝒂'.into(), LetterAttr::Default)),
1822            "<mi>𝒂</mi>"
1823        );
1824        assert_eq!(
1825            render(&Node::IdentifierStr("𝒂𝒃𝒄")),
1826            "<mrow><mspace/><mi>𝒂𝒃𝒄</mi></mrow>"
1827        );
1828    }
1829
1830    #[test]
1831    fn render_enclose() {
1832        let content = Node::Row {
1833            nodes: &[
1834                &Node::IdentifierChar('a'.into(), LetterAttr::Default),
1835                &Node::IdentifierChar('b'.into(), LetterAttr::Default),
1836                &Node::IdentifierChar('c'.into(), LetterAttr::Default),
1837            ],
1838            attrs: RowAttrs::DEFAULT,
1839        };
1840
1841        assert_eq!(
1842            render(&Node::Enclose {
1843                content: &content,
1844                notation: Notation::UP_DIAGONAL | Notation::DOWN_DIAGONAL
1845            }),
1846            "<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>"
1847        );
1848    }
1849
1850    #[test]
1851    fn fragment_encoding() {
1852        let ascii_samples = b"\x00\x01\x1F !\"#$%&'()*+,-./09:;<=>?@AZ[\\]^_`az{|}~\x7F";
1853        let encoded = percent_encode(ascii_samples, FRAGMENT_SAFE).to_string();
1854        assert_eq!(
1855            encoded,
1856            "%00%01%1F%20!%22%23$%25&'()*+,-./09:;%3C=%3E?@AZ%5B%5C%5D%5E_%60az%7B%7C%7D~%7F"
1857        );
1858    }
1859}