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