//! Rendering of mathematical notation for the documents.
//!
//! The documents carry formulas, and the reader needs them to look like
//! formulas rather than like source. A full TeX engine is a large dependency
//! with its own font files, so the notation used here is translated to
//! MathML instead. Browsers lay out and print MathML themselves, so no
//! script has to run and the output works without JavaScript.
//!
//! The subset is the one orbital mechanics actually uses: fractions,
//! superscripts, subscripts, roots, Greek letters, the common operators, and
//! the delimiters that group them. An expression outside the subset is shown
//! as written rather than dropped, so an unsupported command costs nothing
//! but looks plain.
//!
//! Two forms are recognised, following the convention the documents use:
//! `$...$` for a formula within a sentence and `$$...$$` for one on its own
//! line.
use crate::docs::markdown::{escape, escape_attr};
/// Converts a formula to MathML wrapped in a container.
///
/// `display` is set when the formula stands on its own line, which makes it
/// larger and centred.
pub fn render(latex: &str, display: bool) -> String {
// The delimiters of a display formula are on their own lines, so the
// source arrives with the newline that followed the opening pair.
let latex = latex.trim();
let body = to_mathml(&normalize(latex));
let class = if display { "math math-display" } else { "math" };
// The original source is kept in a data attribute so that the formula
// can be recovered from the page, for copying and for the tests.
// The MathML is one element per token, and a browser copying a selection
// walks them one at a time: a formula copied from the page arrives as one
// character per line. The source is therefore carried beside it in an
// element a reader can select, and the MathML is taken out of selection
// so that what a copy yields is the formula as it was written.
format!(
"{source}",
source = escape_attr(latex),
display = if display { "block" } else { "inline" }
)
}
/// Rewrites the source into the tokens the translation loop reads.
///
/// Braces are turned into single characters so that a group is one unit, and
/// the spacing commands are dropped.
pub(crate) fn normalize(latex: &str) -> Vec {
let characters: Vec = latex.chars().collect();
let mut tokens: Vec = Vec::with_capacity(characters.len());
let mut index = 0;
while index < characters.len() {
let character = characters[index];
match character {
'{' | '}' => {
tokens.push(Token::Group);
index += 1;
}
'\\' => {
// A command runs to the first non letter.
let mut end = index + 1;
while end < characters.len() && characters[end].is_ascii_alphabetic() {
end += 1;
}
if end == index + 1 {
// A backslash before a mark is a command of its own when
// the mark is a spacing one, and that character itself
// otherwise.
match characters.get(index + 1) {
Some(',') => tokens.push(Token::Command("thinspace".to_string())),
Some(';') => tokens.push(Token::Command("thickspace".to_string())),
Some(':') => tokens.push(Token::Command("medspace".to_string())),
Some('!') => tokens.push(Token::Command("negspace".to_string())),
Some(' ') => tokens.push(Token::Command("space".to_string())),
Some(next) => tokens.push(Token::Symbol(*next)),
None => {}
}
index += 2;
} else {
let name: String = characters[index + 1..end].iter().collect();
tokens.push(Token::Command(name));
index = end;
}
}
_ => {
tokens.push(Token::Symbol(character));
index += 1;
}
}
}
tokens
}
/// One unit of the formula after normalization.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Token {
/// A single character.
Symbol(char),
/// A backslash command, without the backslash.
Command(String),
/// An opening or closing brace; which one is taken from the position.
Group,
}
/// A parsed expression, ready to become MathML.
#[derive(Debug, Clone, PartialEq, Eq)]
enum Node {
/// Text, already escaped.
Text(String),
/// A row of children.
Row(Vec),
/// A fraction.
Fraction(Box, Box),
/// A root.
Root(Box, Option>),
/// A superscript.
Superscript(Box, Box),
/// A subscript.
Subscript(Box, Box),
/// A subscript and a superscript on one base.
Scripts(Box, Box, Box),
/// A named function such as `sin`.
Function(String),
/// A mark written above its argument, as in `\dot\Omega`.
Overscript(Box, String),
/// Words that stand as they are written, as in `\text{km}`.
Verbatim(String),
/// A matrix, with the delimiters its environment asks for.
Matrix {
/// The opening and closing delimiters, which may be empty.
open: &'static str,
close: &'static str,
/// The cells, one row at a time.
rows: Vec>,
},
}
/// The Greek letters, by command.
fn greek(name: &str) -> Option<&'static str> {
Some(match name {
"alpha" => "α",
"beta" => "β",
"gamma" => "γ",
"delta" => "δ",
"epsilon" => "ε",
"varepsilon" => "ε",
"zeta" => "ζ",
"eta" => "η",
"theta" => "θ",
"vartheta" => "ϑ",
"iota" => "ι",
"kappa" => "κ",
"lambda" => "λ",
"mu" => "μ",
"nu" => "ν",
"xi" => "ξ",
"pi" => "π",
"varpi" => "ϖ",
"rho" => "ρ",
"varrho" => "ϱ",
"sigma" => "σ",
"varsigma" => "ς",
"tau" => "τ",
"upsilon" => "υ",
"phi" => "φ",
"varphi" => "ϕ",
"chi" => "χ",
"psi" => "ψ",
"omega" => "ω",
"Gamma" => "Γ",
"Delta" => "Δ",
"Theta" => "Θ",
"Lambda" => "Λ",
"Xi" => "Ξ",
"Pi" => "Π",
"Sigma" => "Σ",
"Upsilon" => "Υ",
"Phi" => "Φ",
"Psi" => "Ψ",
"Omega" => "Ω",
_ => return None,
})
}
/// The operators, by command.
pub(crate) fn operator(name: &str) -> Option<&'static str> {
Some(match name {
"times" => "×",
"cdot" => "⋅",
"div" => "÷",
"pm" => "±",
"mp" => "∓",
"leq" | "le" => "≤",
"geq" | "ge" => "≥",
"neq" | "ne" => "≠",
"approx" => "≈",
"sim" => "∼",
"propto" => "∝",
"equiv" => "≡",
"ll" => "≪",
"gg" => "≫",
"to" | "rightarrow" => "→",
"leftarrow" => "←",
"Rightarrow" => "⇒",
"Leftarrow" => "⇐",
"in" => "∈",
"notin" => "∉",
"subset" => "⊂",
"subseteq" => "⊆",
"supset" => "⊃",
"infty" => "∞",
"partial" => "∂",
"nabla" => "∇",
"sum" => "∑",
"prod" => "∏",
"int" => "∫",
"forall" => "∀",
"exists" => "∃",
"circ" => "∘",
_ => return None,
})
}
/// The functions, by command; these are set upright rather than italic.
fn function(name: &str) -> Option<&'static str> {
Some(match name {
"sin" => "sin",
"cos" => "cos",
"tan" => "tan",
"cot" => "cot",
"sec" => "sec",
"csc" => "csc",
"arcsin" => "arcsin",
"arccos" => "arccos",
"arctan" => "arctan",
"sinh" => "sinh",
"cosh" => "cosh",
"tanh" => "tanh",
"log" => "log",
"ln" => "ln",
"lg" => "lg",
"exp" => "exp",
"min" => "min",
"max" => "max",
"det" => "det",
"mod" => "mod",
"arg" => "arg",
"lim" => "lim",
_ => return None,
})
}
/// Renders a token sequence to MathML.
pub(crate) fn to_mathml(tokens: &[Token]) -> String {
let mut cursor = 0;
let row = parse_row(tokens, &mut cursor, None);
match row.len() {
1 => emit(&row[0]),
_ => emit(&Node::Row(row)),
}
}
/// Parses until the end of the input or, when `stop` is given, until the
/// group that ends the enclosing group.
fn parse_row(tokens: &[Token], cursor: &mut usize, stop: Option<&[char]>) -> Vec {
let mut row: Vec = Vec::new();
while *cursor < tokens.len() {
match &tokens[*cursor] {
// A closing brace ends the enclosing group; a delimiter set
// ends the stretchy group.
Token::Group => {
if stop.is_some_and(|set| set.contains(&'}')) {
*cursor += 1;
return row;
}
if stop.is_some_and(|set| set.contains(&')')) {
return row;
}
*cursor += 1;
let inner = parse_row(tokens, cursor, Some(&['}']));
row.push(Node::Row(inner));
}
Token::Symbol(symbol) => {
if stop.is_some_and(|set| set.contains(symbol)) {
return row;
}
*cursor += 1;
row.extend(parse_symbol(*symbol, tokens, cursor, &mut 0));
}
Token::Command(name) => {
if stop.is_some_and(|set| {
set.contains(&name.as_str()[..1].chars().next().unwrap_or(' '))
}) {
return row;
}
*cursor += 1;
row.extend(parse_command(name, tokens, cursor));
}
}
}
row
}
/// Returns the delimiters a matrix environment is drawn with.
fn matrix_delimiters(name: &str) -> Option<(&'static str, &'static str)> {
Some(match name {
"matrix" | "aligned" | "align" | "gathered" | "array" => ("", ""),
"pmatrix" => ("(", ")"),
"bmatrix" => ("[", "]"),
"vmatrix" => ("|", "|"),
"Vmatrix" => ("‖", "‖"),
"cases" => ("{", ""),
_ => return None,
})
}
/// Reads the name of an environment from the group that follows `\begin`.
fn parse_environment_name(tokens: &[Token], cursor: &mut usize) -> String {
let mut name = String::new();
if !matches!(tokens.get(*cursor), Some(Token::Group)) {
return name;
}
*cursor += 1;
while let Some(token) = tokens.get(*cursor) {
if token == &Token::Group {
break;
}
if let Token::Symbol(character) = token {
name.push(*character);
}
*cursor += 1;
}
if matches!(tokens.get(*cursor), Some(Token::Group)) {
*cursor += 1;
}
name
}
/// Reads the rows of a matrix, up to the `\end` that closes it.
///
/// A cell ends at `&`, a row ends at `\\`, and the environment ends at
/// `\end`. The tokens of a cell are read by the same handlers as anywhere
/// else, so a fraction or a root inside a cell works as it does outside.
fn parse_matrix_rows(tokens: &[Token], cursor: &mut usize) -> Vec> {
// Two accumulators: the cell being read, and the cells of the row so
// far. One alone does not do, because `&` ends a cell and starts the
// next, and the nodes after it belong to the next cell rather than
// beside the one just closed.
let mut rows: Vec> = Vec::new();
let mut cells: Vec = Vec::new();
let mut cell: Vec = Vec::new();
let end_cell = |cells: &mut Vec, cell: &mut Vec| {
cells.push(Node::Row(std::mem::take(cell)));
};
loop {
match tokens.get(*cursor) {
None => break,
Some(Token::Command(name)) if name == "end" => break,
Some(Token::Symbol('\\')) => {
*cursor += 1;
end_cell(&mut cells, &mut cell);
rows.push(std::mem::take(&mut cells));
}
Some(Token::Symbol('&')) => {
*cursor += 1;
end_cell(&mut cells, &mut cell);
}
Some(Token::Group) => {
*cursor += 1;
let inner = parse_row(tokens, cursor, Some(&['}']));
cell.push(Node::Row(inner));
}
Some(Token::Symbol(symbol)) => {
let symbol = *symbol;
*cursor += 1;
cell.extend(parse_symbol(symbol, tokens, cursor, &mut 0));
}
Some(Token::Command(name)) => {
let name = name.clone();
*cursor += 1;
cell.extend(parse_command(&name, tokens, cursor));
}
}
}
// The last row has no `\\` after it.
if !cell.is_empty() || !cells.is_empty() || rows.is_empty() {
end_cell(&mut cells, &mut cell);
rows.push(cells);
}
rows
}
/// Reads the characters of an argument as the text they are written with.
///
/// Used where the argument is words rather than mathematics, so that the
/// letters are not read as variables one at a time.
fn parse_raw_text(tokens: &[Token], cursor: &mut usize) -> String {
let mut out = String::new();
if !matches!(tokens.get(*cursor), Some(Token::Group)) {
return out;
}
*cursor += 1;
while let Some(token) = tokens.get(*cursor) {
match token {
Token::Group => break,
Token::Symbol(character) => out.push(*character),
Token::Command(name) => out.push_str(name),
}
*cursor += 1;
}
if matches!(tokens.get(*cursor), Some(Token::Group)) {
*cursor += 1;
}
out
}
/// Reads the argument of a command, which is either a group or one symbol.
fn parse_argument(tokens: &[Token], cursor: &mut usize) -> Node {
match tokens.get(*cursor) {
Some(Token::Group) => {
*cursor += 1;
Node::Row(parse_row(tokens, cursor, Some(&['}'])))
}
// A square bracket is a group too, and is how a root takes its
// index: `\sqrt[3]{x}`.
Some(Token::Symbol('[')) => {
*cursor += 1;
let inner = parse_row(tokens, cursor, Some(&[',', ']']));
if matches!(tokens.get(*cursor), Some(Token::Symbol(']'))) {
*cursor += 1;
}
Node::Row(inner)
}
Some(Token::Symbol(symbol)) => {
let symbol = *symbol;
*cursor += 1;
Node::Text(symbol.to_string())
}
Some(Token::Command(name)) => {
let name = name.clone();
*cursor += 1;
match greek(&name) {
Some(text) => Node::Text(text.to_string()),
None => match operator(&name) {
Some(text) => Node::Text(text.to_string()),
None => Node::Text(format!("\\{name}")),
},
}
}
None => Node::Text(String::new()),
}
}
/// Reads the scripts that follow a base, and returns the node with them.
fn attach_scripts(base: Node, tokens: &[Token], cursor: &mut usize) -> Node {
let mut superscript: Option = None;
let mut subscript: Option = None;
loop {
let marker = match tokens.get(*cursor) {
Some(Token::Symbol('^')) => '^',
Some(Token::Symbol('_')) => '_',
Some(Token::Command(name)) if name == "sup" => '^',
Some(Token::Command(name)) if name == "sub" => '_',
_ => break,
};
*cursor += 1;
let argument = parse_argument(tokens, cursor);
if marker == '^' {
superscript = Some(argument);
} else {
subscript = Some(argument);
}
}
match (subscript, superscript) {
(None, None) => base,
(None, Some(top)) => Node::Superscript(Box::new(base), Box::new(top)),
(Some(bottom), None) => Node::Subscript(Box::new(base), Box::new(bottom)),
(Some(bottom), Some(top)) => Node::Scripts(Box::new(base), Box::new(bottom), Box::new(top)),
}
}
/// The digit at a position.
fn characters_digit(tokens: &[Token], cursor: usize) -> char {
match tokens.get(cursor) {
Some(Token::Symbol(symbol)) => *symbol,
_ => '0',
}
}
/// Handles a plain character, including the brackets that group.
fn parse_symbol(symbol: char, tokens: &[Token], cursor: &mut usize, _depth: &mut u32) -> Vec {
let base = match symbol {
// Digits are read together as one number, so that `12` is a number
// rather than two identifiers. A dot between digits belongs to it,
// which also stops the layout breaking the number across a line.
'0'..='9' => {
let mut text = symbol.to_string();
loop {
match tokens.get(*cursor) {
Some(Token::Symbol('.'))
if matches!(tokens.get(*cursor + 1), Some(Token::Symbol('0'..='9'))) =>
{
text.push('.');
*cursor += 1;
}
Some(Token::Symbol('0'..='9')) => {
text.push(characters_digit(tokens, *cursor));
*cursor += 1;
}
_ => break,
}
}
Node::Text(text)
}
// An opening bracket stretches to the height of what it contains.
//
// A bracketed group is a base like any other, so it is given its
// scripts by the same call at the end of this function. Returning
// the group from here instead left `(a + b)^2` with the caret set as
// an ordinary symbol and the exponent beside it, and inside a
// fraction the exponent escaped the fraction altogether.
'(' => {
let inner = parse_row(tokens, cursor, Some(&[',', ')']));
if matches!(tokens.get(*cursor), Some(Token::Symbol(')'))) {
*cursor += 1;
}
Node::Row(vec![
Node::Text("(".to_string()),
Node::Row(inner),
Node::Text(")".to_string()),
])
}
'[' => {
let inner = parse_row(tokens, cursor, Some(&[',', ']']));
if matches!(tokens.get(*cursor), Some(Token::Symbol(']'))) {
*cursor += 1;
}
Node::Row(vec![
Node::Text("[".to_string()),
Node::Row(inner),
Node::Text("]".to_string()),
])
}
'{' => Node::Row(parse_row(tokens, cursor, Some(&['}']))),
'_' | '^' => Node::Text(symbol.to_string()),
// The spaces are kept between words and dropped elsewhere, which is
// what keeps `a b` apart without leaving gaps after a bracket.
other => Node::Text(other.to_string()),
};
vec![attach_scripts(base, tokens, cursor)]
}
/// Handles a backslash command.
fn parse_command(name: &str, tokens: &[Token], cursor: &mut usize) -> Vec {
if let Some(letter) = greek(name) {
return vec![attach_scripts(
Node::Text(letter.to_string()),
tokens,
cursor,
)];
}
if let Some(symbol) = operator(name) {
return vec![attach_scripts(
Node::Text(symbol.to_string()),
tokens,
cursor,
)];
}
if let Some(label) = function(name) {
// A function is upright, so it is emitted as its own node rather
// than as an italic variable.
return vec![attach_scripts(
Node::Function(label.to_string()),
tokens,
cursor,
)];
}
match name {
"frac" | "dfrac" | "tfrac" => {
let top = parse_argument(tokens, cursor);
let bottom = parse_argument(tokens, cursor);
vec![attach_scripts(
Node::Fraction(Box::new(top), Box::new(bottom)),
tokens,
cursor,
)]
}
// A matrix environment: \begin{name} cells \end{name}, with `&`
// between the cells of a row and `\\` between the rows.
"begin" => {
let name = parse_environment_name(tokens, cursor);
let Some((open, close)) = matrix_delimiters(&name) else {
return Vec::new();
};
let rows = parse_matrix_rows(tokens, cursor);
// The environment is closed by `\end{name}`, which is two
// tokens: the command and the name. Both are read here, or the
// name is left to be set as mathematics of its own.
if matches!(tokens.get(*cursor), Some(Token::Command(name)) if name == "end") {
*cursor += 1;
let _ = parse_raw_text(tokens, cursor);
}
vec![Node::Matrix { open, close, rows }]
}
// The closing of an environment is consumed by the environment.
"end" => Vec::new(),
// A floor, a ceiling, and the modulo operator.
"lfloor" | "lceil" | "langle" => {
let symbol = match name {
"lfloor" => "⌊",
"lceil" => "⌈",
_ => "⟨",
};
vec![Node::Text(symbol.to_string())]
}
"rfloor" | "rceil" | "rangle" => {
let symbol = match name {
"rfloor" => "⌋",
"rceil" => "⌉",
_ => "⟩",
};
vec![Node::Text(symbol.to_string())]
}
// A vertical bar names an absolute value or a norm. Written as a
// command it is a delimiter like any other, and was being shown as
// the letters of its own name.
"lvert" | "rvert" | "vert" => {
vec![attach_scripts(Node::Text("|".to_string()), tokens, cursor)]
}
"lVert" | "rVert" | "Vert" => {
vec![attach_scripts(Node::Text("‖".to_string()), tokens, cursor)]
}
"lbrace" | "rbrace" => {
vec![attach_scripts(Node::Text("{".to_string()), tokens, cursor)]
}
"bmod" => vec![Node::Text(" mod ".to_string())],
"pmod" => {
let argument = parse_argument(tokens, cursor);
vec![Node::Row(vec![
Node::Text(" (mod ".to_string()),
argument,
Node::Text(")".to_string()),
])]
}
// An accent, written as a mark above its argument.
"dot" | "ddot" | "bar" | "overline" | "vec" | "hat" | "tilde" => {
let mark = match name {
"dot" => "˙",
"ddot" => "¨",
"vec" => "→",
"hat" => "^",
"tilde" => "~",
_ => "‾",
};
let argument = parse_argument(tokens, cursor);
let accented = Node::Overscript(Box::new(argument), mark.to_string());
vec![attach_scripts(accented, tokens, cursor)]
}
// Commands that only set the size or the style, and carry no glyph.
"thinspace" => vec![Node::Text("\u{2009}".to_string())],
"medspace" | "thickspace" => vec![Node::Text("\u{2005}".to_string())],
"negspace" => Vec::new(),
"space" => vec![Node::Text(" ".to_string())],
"quad" | "qquad" => vec![Node::Text("\u{2009}\u{2009}".to_string())],
"displaystyle" | "textstyle" | "limits" | "nolimits" | "left" | "right" | "bigl"
| "bigr" | "Bigl" | "Bigr" | "biggl" | "biggr" | "Biggl" | "Biggr" | "big" | "Big"
| "bigg" | "Bigg" => {
// A size or a pairing command is structural. The delimiter that
// follows is read as an ordinary symbol.
Vec::new()
}
"sqrt" => {
// The index, when there is one, is given in brackets before the
// radicand: `\sqrt[3]{x}`.
let degree = if matches!(tokens.get(*cursor), Some(Token::Symbol('['))) {
Some(Box::new(parse_argument(tokens, cursor)))
} else {
None
};
let radicand = parse_argument(tokens, cursor);
vec![attach_scripts(
Node::Root(Box::new(radicand), degree),
tokens,
cursor,
)]
}
// A comma written as a command is punctuation, not a separator
// between two formulas, so it keeps its own shape.
"," => vec![Node::Text(", ".to_string())],
// `\text{...}` holds words as they are written. Read as variables
// they would be set one letter at a time, so `km` became `k m`.
"text" | "mathrm" | "operatorname" | "mbox" => {
let words = Node::Verbatim(parse_raw_text(tokens, cursor));
vec![attach_scripts(words, tokens, cursor)]
}
// A style command keeps its argument set as mathematics. The group
// it holds is a base, so it takes the scripts that follow it: the
// `_central` of `\mathbf{a}_{\text{central}}` belongs to the `a`.
"mathsf" | "mathtt" | "mathbf" | "mathit" | "mathcal" | "mathfrak" => {
let inner = parse_argument(tokens, cursor);
vec![attach_scripts(Node::Row(vec![inner]), tokens, cursor)]
}
// An unknown command is shown as written, so that a formula that
// uses one still reads rather than losing part of itself.
other => vec![Node::Text(format!("\\{other}"))],
}
}
/// Writes a piece of text as the MathML element its kind calls for.
///
/// Every branch produces an element, and the last one produces ``.
/// Text left bare inside a row is dropped by the browser, which loses part of
/// a formula without any sign that something is missing: a multi-digit number
/// such as the 4800 in `year + 4800` simply did not appear, and neither did
/// the name `delta_v` before it was given an element.
fn classify(text: &str) -> String {
if text.is_empty() {
return String::new();
}
let first = text.chars().next().unwrap_or(' ');
let all = |predicate: fn(char) -> bool| text.chars().all(predicate);
// A space is a space. It is written as an explicit one rather than as
// text, because a run of spaces between two elements carries no glyph.
if all(char::is_whitespace) {
return "".to_string();
}
// A variable is one letter, whatever script it is written in. Greek is
// set as an identifier so that it is drawn as a variable rather than as
// upright text.
if text.chars().count() == 1 && first.is_alphabetic() {
return format!("{}", escape(text));
}
// A number starts with a digit. It may hold a decimal point or an
// exponent, as in `1.5` or `1e-3`.
if first.is_ascii_digit()
&& all(|c| c.is_ascii_digit() || c == '.' || c == 'e' || c == 'E' || c == '-' || c == '+')
{
return format!("{}", escape(text));
}
// A name of Latin letters is an identifier, whether it is one letter or
// several, as in `a` or `delta_v`.
if all(|c| c.is_ascii_alphabetic()) {
return format!("{}", escape(text));
}
// An operator, including one written with its spacing, as in ` = `.
// The spaces are the operator's own width, so they are trimmed before
// the test rather than counted against it.
let trimmed = text.trim();
if !trimmed.is_empty() && trimmed.chars().all(|c| !c.is_alphanumeric()) {
return format!("{}", escape(trimmed));
}
// Anything else is text: a label in another script, or a phrase. It is
// set upright and kept whole rather than being read as variables.
format!("{}", escape(text))
}
/// Writes a node as MathML.
fn emit(node: &Node) -> String {
match node {
// MathML distinguishes an identifier, a number and operators, and a
// browser uses the element to choose the font: an upright digit in a
// serif face reads as a number rather than as a variable.
Node::Text(text) => classify(text),
Node::Function(label) => format!("{}", escape(label)),
Node::Verbatim(text) => format!("{}", escape(text)),
Node::Overscript(base, mark) => format!(
"{}{}",
emit(base),
escape(mark)
),
Node::Matrix { open, close, rows } => {
let body: String = rows
.iter()
.map(|row| {
let cells: String = row
.iter()
.map(|cell| format!("{}", emit(cell)))
.collect();
format!("{cells}")
})
.collect();
let table = format!("{body}");
if open.is_empty() && close.is_empty() {
table
} else {
format!("{open}{table}{close}")
}
}
Node::Row(children) => match children.len() {
// A row of one is the child itself; an extra pair of brackets
// would be noise in the markup and would break the grouping the
// element's layout relies on.
1 => emit(&children[0]),
_ => {
let body: String = children.iter().map(emit).collect();
format!("{body}")
}
},
Node::Fraction(top, bottom) => format!(
"{}{}",
emit(top),
emit(bottom)
),
Node::Root(radicand, degree) => match degree {
Some(degree) => format!(
"{}{}",
emit(radicand),
emit(degree)
),
None => format!("{}", emit(radicand)),
},
Node::Superscript(base, top) => format!(
"{}{}",
emit(base),
emit(top)
),
Node::Subscript(base, bottom) => format!(
"{}{}",
emit(base),
emit(bottom)
),
Node::Scripts(base, bottom, top) => format!(
"{}{}{}",
emit(base),
emit(bottom),
emit(top)
),
}
}