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