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