Skip to main content

math_core_renderer_internal/
ast.rs

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