From d84e325c8598b7f639b3c491f565c2bd194e62bf Mon Sep 17 00:00:00 2001 From: Thomas M Kehrenberg Date: Fri, 6 Mar 2026 11:43:50 +0100 Subject: [PATCH 1/3] Add `symmetric="true"` to `OpAttrs` --- crates/mathml-renderer/src/attribute.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/mathml-renderer/src/attribute.rs b/crates/mathml-renderer/src/attribute.rs index 7360aa29..495e6b7d 100644 --- a/crates/mathml-renderer/src/attribute.rs +++ b/crates/mathml-renderer/src/attribute.rs @@ -33,7 +33,9 @@ bitflags! { const NO_MOVABLE_LIMITS = 1 << 2; const FORCE_MOVABLE_LIMITS = 1 << 3; const FORM_PREFIX = 1 << 4; - const FORM_POSTFIX = 1 << 5; + // const FORM_INFIX = 1 << 5; + const FORM_POSTFIX = 1 << 6; + const SYMMETRIC_TRUE = 1 << 7; } } @@ -57,6 +59,9 @@ impl OpAttrs { if self.contains(OpAttrs::FORM_POSTFIX) { s.push_str(r#" form="postfix""#); } + if self.contains(OpAttrs::SYMMETRIC_TRUE) { + s.push_str(r#" symmetric="true""#); + } } } From 33e97d82d6a165afa1db1d19bc75bc107213d285 Mon Sep 17 00:00:00 2001 From: Thomas M Kehrenberg Date: Fri, 6 Mar 2026 11:57:14 +0100 Subject: [PATCH 2/3] Remove `Node::StretchableOp` --- crates/math-core/src/parser.rs | 68 ++++++++++++------- ...h_core__parser__tests__pmod_subscript.snap | 14 +++- .../conversion_test__bra_and_ket.snap | 4 +- .../conversion_test__lVert_and_rVert2.snap | 4 +- .../conversion_test__lvert_and_rvert.snap | 4 +- .../tests/snapshots/wiki_test__wiki020.snap | 4 +- crates/mathml-renderer/src/ast.rs | 39 ++--------- crates/mathml-renderer/src/attribute.rs | 11 --- crates/mathml-renderer/src/symbol.rs | 7 ++ 9 files changed, 79 insertions(+), 76 deletions(-) diff --git a/crates/math-core/src/parser.rs b/crates/math-core/src/parser.rs index 6fd252c9..c88a8009 100644 --- a/crates/math-core/src/parser.rs +++ b/crates/math-core/src/parser.rs @@ -4,11 +4,10 @@ use mathml_renderer::{ arena::{Arena, Buffer}, ast::Node, attribute::{ - LetterAttr, MathSpacing, MathVariant, OpAttrs, ParenType, RowAttr, StretchMode, Style, - TextTransform, + LetterAttr, MathSpacing, MathVariant, OpAttrs, ParenType, RowAttr, Style, TextTransform, }, length::Length, - symbol::{self, OpCategory, OrdCategory, RelCategory, StretchableOp}, + symbol::{self, OpCategory, OrdCategory, RelCategory, StretchableOp, Stretchy}, }; use crate::{ @@ -927,30 +926,33 @@ where } else { OpAttrs::empty() }; - Ok(Node::StretchableOp( - stretchable_op, - StretchMode::NoStretch, - attr, - )) + Ok(Node::Operator { + op: stretchable_op.as_op(), + attrs: attr | no_stretch_attrs(stretchable_op), + left: None, + right: None, + }) } Token::SquareBracketOpen => { class = Class::Open; const SQ_L_BRACKET: StretchableOp = symbol::LEFT_SQUARE_BRACKET.as_stretchable_op().unwrap(); - Ok(Node::StretchableOp( - SQ_L_BRACKET, - StretchMode::NoStretch, - OpAttrs::empty(), - )) + Ok(Node::Operator { + op: SQ_L_BRACKET.as_op(), + attrs: no_stretch_attrs(SQ_L_BRACKET), + left: None, + right: None, + }) } Token::SquareBracketClose => { const SQ_R_BRACKET: StretchableOp = symbol::RIGHT_SQUARE_BRACKET.as_stretchable_op().unwrap(); - Ok(Node::StretchableOp( - SQ_R_BRACKET, - StretchMode::NoStretch, - OpAttrs::empty(), - )) + Ok(Node::Operator { + op: SQ_R_BRACKET.as_op(), + attrs: no_stretch_attrs(SQ_R_BRACKET), + left: None, + right: None, + }) } Token::Left => { let tok_loc = self.next_token()?; @@ -982,11 +984,12 @@ where Token::Middle => { let tok_loc = self.next_token()?; let op = extract_delimiter(tok_loc, DelimiterModifier::Middle)?; - Ok(Node::StretchableOp( - op, - StretchMode::Middle, - OpAttrs::empty(), - )) + Ok(Node::Operator { + op: op.as_op(), + attrs: middle_stretch_attrs(op), + left: None, + right: None, + }) } Token::Big(size, paren_type) => { let tok_loc = self.next_token()?; @@ -1761,6 +1764,25 @@ pub(crate) fn node_vec_to_node<'arena>( } } +/// Get the attributes for an operator that we want not to stretch. +fn no_stretch_attrs(op: StretchableOp) -> OpAttrs { + match op.stretchy { + Stretchy::Always | Stretchy::PrePostfix | Stretchy::AlwaysAsymmetric => { + OpAttrs::STRETCHY_FALSE + } + _ => OpAttrs::empty(), + } +} + +/// Get the attributes for a middle operator (which needs to stretch symmetrically). +fn middle_stretch_attrs(op: StretchableOp) -> OpAttrs { + match op.stretchy { + Stretchy::PrePostfix | Stretchy::Never => OpAttrs::STRETCHY_TRUE, + Stretchy::AlwaysAsymmetric => OpAttrs::SYMMETRIC_TRUE, + _ => OpAttrs::empty(), + } +} + fn extract_delimiter(tok: TokSpan<'_>, location: DelimiterModifier) -> ParseResult { let (tok, span) = tok.into_parts(); const SQ_L_BRACKET: StretchableOp = symbol::LEFT_SQUARE_BRACKET.as_stretchable_op().unwrap(); diff --git a/crates/math-core/src/snapshots/math_core__parser__tests__pmod_subscript.snap b/crates/math-core/src/snapshots/math_core__parser__tests__pmod_subscript.snap index 0d630b78..ca2a73c1 100644 --- a/crates/math-core/src/snapshots/math_core__parser__tests__pmod_subscript.snap +++ b/crates/math-core/src/snapshots/math_core__parser__tests__pmod_subscript.snap @@ -7,7 +7,12 @@ expression: "\\pmod{3}_4" value: 1.0, unit: Em, )), - StretchableOp(StretchableOp('(', Always), NoStretch, OpAttrs("")), + Operator( + op: '(', + attrs: OpAttrs("STRETCHY_FALSE"), + left: None, + right: None, + ), IdentifierStr("mod"), Space(Length( value: 0.33333334, @@ -15,7 +20,12 @@ expression: "\\pmod{3}_4" )), Number("3"), Sub( - target: StretchableOp(StretchableOp(')', Always), NoStretch, OpAttrs("")), + target: Operator( + op: ')', + attrs: OpAttrs("STRETCHY_FALSE"), + left: None, + right: None, + ), symbol: Number("4"), ), ] diff --git a/crates/math-core/tests/snapshots/conversion_test__bra_and_ket.snap b/crates/math-core/tests/snapshots/conversion_test__bra_and_ket.snap index 634161a3..7646878b 100644 --- a/crates/math-core/tests/snapshots/conversion_test__bra_and_ket.snap +++ b/crates/math-core/tests/snapshots/conversion_test__bra_and_ket.snap @@ -6,9 +6,9 @@ expression: "x\\bra{\\uparrow} + \\ket{\\downarrow}y" x - | + | + - | + | y diff --git a/crates/math-core/tests/snapshots/conversion_test__lVert_and_rVert2.snap b/crates/math-core/tests/snapshots/conversion_test__lVert_and_rVert2.snap index e94cd768..88d97a6b 100644 --- a/crates/math-core/tests/snapshots/conversion_test__lVert_and_rVert2.snap +++ b/crates/math-core/tests/snapshots/conversion_test__lVert_and_rVert2.snap @@ -5,11 +5,11 @@ expression: "x + \\lVert + y + \\rVert + z" x + - + + y + - + + z diff --git a/crates/math-core/tests/snapshots/conversion_test__lvert_and_rvert.snap b/crates/math-core/tests/snapshots/conversion_test__lvert_and_rvert.snap index 144a99ae..851635ee 100644 --- a/crates/math-core/tests/snapshots/conversion_test__lvert_and_rvert.snap +++ b/crates/math-core/tests/snapshots/conversion_test__lvert_and_rvert.snap @@ -5,11 +5,11 @@ expression: "x + \\lvert + y + \\rvert + z" x + - | + | + y + - | + | + z diff --git a/crates/math-core/tests/snapshots/wiki_test__wiki020.snap b/crates/math-core/tests/snapshots/wiki_test__wiki020.snap index c9524e82..37315b76 100644 --- a/crates/math-core/tests/snapshots/wiki_test__wiki020.snap +++ b/crates/math-core/tests/snapshots/wiki_test__wiki020.snap @@ -9,9 +9,9 @@ expression: "\\Pr j, \\hom l, \\lVert z \\rVert, \\arg z" hom l , - + z - + , arg z diff --git a/crates/mathml-renderer/src/ast.rs b/crates/mathml-renderer/src/ast.rs index cf994bc7..aa3e4990 100644 --- a/crates/mathml-renderer/src/ast.rs +++ b/crates/mathml-renderer/src/ast.rs @@ -6,7 +6,7 @@ use serde::Serialize; use crate::attribute::{ FracAttr, HtmlTextStyle, LetterAttr, MathSpacing, Notation, OpAttrs, ParenType, RowAttr, Size, - StretchMode, Style, + Style, }; use crate::fmt::new_line_and_indent; use crate::itoa::append_u8_as_hex; @@ -31,7 +31,6 @@ pub enum Node<'arena> { left: Option, right: Option, }, - StretchableOp(StretchableOp, StretchMode, OpAttrs), /// `...` for a string. PseudoOp { attrs: OpAttrs, @@ -198,9 +197,6 @@ impl Node<'_> { write!(s, "")?; } } - Node::StretchableOp(op, stretch_mode, attr) => { - emit_stretchy_op(s, *stretch_mode, Some(*op), *attr)?; - } Node::Operator { op, attrs: attr, @@ -409,11 +405,11 @@ impl Node<'_> { None => write!(s, "")?, } new_line_and_indent(s, child_indent); - emit_stretchy_op(s, StretchMode::Fence, *open, OpAttrs::empty())?; + emit_fence(s, *open, OpAttrs::empty())?; // TODO: if `content` is an `mrow`, we should flatten it before emitting. content.emit(s, child_indent)?; new_line_and_indent(s, child_indent); - emit_stretchy_op(s, StretchMode::Fence, *close, OpAttrs::empty())?; + emit_fence(s, *close, OpAttrs::empty())?; writeln_indent!(s, base_indent, ""); } Node::SizedParen(size, paren, paren_type) => { @@ -760,34 +756,13 @@ fn write_equation_num( Ok(()) } -fn emit_stretchy_op( - s: &mut String, - stretch_mode: StretchMode, - op: Option, - attrs: OpAttrs, -) -> std::fmt::Result { +fn emit_fence(s: &mut String, op: Option, attrs: OpAttrs) -> std::fmt::Result { emit_operator_attributes(s, attrs, None, None)?; if let Some(op) = op { - match (stretch_mode, op.stretchy) { - (StretchMode::Fence, Stretchy::Never) - | (StretchMode::Middle, Stretchy::PrePostfix | Stretchy::Never) => { - write!(s, " stretchy=\"true\">")?; - } - ( - StretchMode::NoStretch, - Stretchy::Always | Stretchy::PrePostfix | Stretchy::AlwaysAsymmetric, - ) => { - write!(s, " stretchy=\"false\">")?; - } - - (StretchMode::Middle, Stretchy::AlwaysAsymmetric) => { - write!(s, " symmetric=\"true\">")?; - } - _ => { - write!(s, ">")?; - } + if matches!(op.stretchy, Stretchy::Never) { + write!(s, " stretchy=\"true\"")?; } - write!(s, "{}", char::from(op))?; + write!(s, ">{}", char::from(op))?; } else { // An empty `` produces weird spacing in some browsers. // Use U+2063 (INVISIBLE SEPARATOR) to work around this. It's in Category K in MathML Core. diff --git a/crates/mathml-renderer/src/attribute.rs b/crates/mathml-renderer/src/attribute.rs index 495e6b7d..8ebb003e 100644 --- a/crates/mathml-renderer/src/attribute.rs +++ b/crates/mathml-renderer/src/attribute.rs @@ -94,17 +94,6 @@ pub enum ParenType { Close, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[cfg_attr(feature = "serde", derive(Serialize))] -pub enum StretchMode { - /// Don't stretch the operator. - NoStretch = 1, - /// Operator is in a fence and should stretch. - Fence, - /// Operator is in the middle of a fenced expression and should stretch. - Middle, -} - /// display style #[derive(Debug, Clone, Copy, PartialEq, IntoStaticStr)] #[cfg_attr(feature = "serde", derive(Serialize))] diff --git a/crates/mathml-renderer/src/symbol.rs b/crates/mathml-renderer/src/symbol.rs index 5f89d469..eb1b3fd0 100644 --- a/crates/mathml-renderer/src/symbol.rs +++ b/crates/mathml-renderer/src/symbol.rs @@ -261,6 +261,13 @@ impl Serialize for StretchableOp { } } +impl StretchableOp { + #[inline] + pub const fn as_op(self) -> MathMLOperator { + MathMLOperator(self.char.as_char()) + } +} + impl From for char { #[inline] fn from(op: StretchableOp) -> Self { From b489ddc68ad65e830631fe41acc8fe53957cd074 Mon Sep 17 00:00:00 2001 From: Thomas M Kehrenberg Date: Fri, 6 Mar 2026 12:36:34 +0100 Subject: [PATCH 3/3] Simplify the code more --- crates/math-core/src/parser.rs | 51 ++++++++++--------------- crates/mathml-renderer/src/attribute.rs | 13 +++++++ crates/mathml-renderer/src/symbol.rs | 2 +- 3 files changed, 34 insertions(+), 32 deletions(-) diff --git a/crates/math-core/src/parser.rs b/crates/math-core/src/parser.rs index c88a8009..f0d763cd 100644 --- a/crates/math-core/src/parser.rs +++ b/crates/math-core/src/parser.rs @@ -907,15 +907,12 @@ where node_vec_to_node(self.arena, &content, matches!(parse_as, ParseAs::Arg)), )); } - ref tok @ (Token::Open(paren) | Token::Close(paren)) => 'open_close: { + ref tok @ (Token::Open(paren) | Token::Close(paren)) => { let open = matches!(tok, Token::Open(_)); if open { class = Class::Open; } - let Some(stretchable_op) = paren.as_stretchable_op() else { - break 'open_close Err(LatexError(span.into(), LatexErrKind::Internal)); - }; - let attr = if matches!(paren.category(), OrdCategory::FGandForceDefault) { + let mut attr = if matches!(paren.category(), OrdCategory::FGandForceDefault) { // For this category of symbol, we have to force the form attribute // in order to get correct spacing. if open { @@ -926,34 +923,36 @@ where } else { OpAttrs::empty() }; + if matches!( + paren.category(), + OrdCategory::F | OrdCategory::G | OrdCategory::FGandForceDefault + ) { + // Symbols from these categories are automatically stretchy, + // so we have to explicitly disable that here. + attr |= OpAttrs::STRETCHY_FALSE; + } Ok(Node::Operator { - op: stretchable_op.as_op(), - attrs: attr | no_stretch_attrs(stretchable_op), + op: paren.as_op(), + attrs: attr, left: None, right: None, }) } Token::SquareBracketOpen => { class = Class::Open; - const SQ_L_BRACKET: StretchableOp = - symbol::LEFT_SQUARE_BRACKET.as_stretchable_op().unwrap(); - Ok(Node::Operator { - op: SQ_L_BRACKET.as_op(), - attrs: no_stretch_attrs(SQ_L_BRACKET), - left: None, - right: None, - }) - } - Token::SquareBracketClose => { - const SQ_R_BRACKET: StretchableOp = - symbol::RIGHT_SQUARE_BRACKET.as_stretchable_op().unwrap(); Ok(Node::Operator { - op: SQ_R_BRACKET.as_op(), - attrs: no_stretch_attrs(SQ_R_BRACKET), + op: symbol::LEFT_SQUARE_BRACKET.as_op(), + attrs: OpAttrs::STRETCHY_FALSE, left: None, right: None, }) } + Token::SquareBracketClose => Ok(Node::Operator { + op: symbol::RIGHT_SQUARE_BRACKET.as_op(), + attrs: OpAttrs::STRETCHY_FALSE, + left: None, + right: None, + }), Token::Left => { let tok_loc = self.next_token()?; let open_paren = if matches!(tok_loc.token(), Token::Letter('.', Mode::MathOrText)) @@ -1764,16 +1763,6 @@ pub(crate) fn node_vec_to_node<'arena>( } } -/// Get the attributes for an operator that we want not to stretch. -fn no_stretch_attrs(op: StretchableOp) -> OpAttrs { - match op.stretchy { - Stretchy::Always | Stretchy::PrePostfix | Stretchy::AlwaysAsymmetric => { - OpAttrs::STRETCHY_FALSE - } - _ => OpAttrs::empty(), - } -} - /// Get the attributes for a middle operator (which needs to stretch symmetrically). fn middle_stretch_attrs(op: StretchableOp) -> OpAttrs { match op.stretchy { diff --git a/crates/mathml-renderer/src/attribute.rs b/crates/mathml-renderer/src/attribute.rs index 8ebb003e..3330fbd2 100644 --- a/crates/mathml-renderer/src/attribute.rs +++ b/crates/mathml-renderer/src/attribute.rs @@ -41,6 +41,19 @@ bitflags! { impl OpAttrs { pub fn write_to(self, s: &mut String) { + debug_assert!( + !(self.contains(OpAttrs::STRETCHY_FALSE) && self.contains(OpAttrs::STRETCHY_TRUE)), + "STRETCHY_FALSE and STRETCHY_TRUE cannot both be set" + ); + debug_assert!( + !(self.contains(OpAttrs::NO_MOVABLE_LIMITS) + && self.contains(OpAttrs::FORCE_MOVABLE_LIMITS)), + "NO_MOVABLE_LIMITS and FORCE_MOVABLE_LIMITS cannot both be set" + ); + debug_assert!( + !(self.contains(OpAttrs::FORM_PREFIX) && self.contains(OpAttrs::FORM_POSTFIX)), + "FORM_PREFIX and FORM_POSTFIX cannot both be set" + ); if self.contains(OpAttrs::STRETCHY_FALSE) { s.push_str(r#" stretchy="false""#); } diff --git a/crates/mathml-renderer/src/symbol.rs b/crates/mathml-renderer/src/symbol.rs index eb1b3fd0..03b5d195 100644 --- a/crates/mathml-renderer/src/symbol.rs +++ b/crates/mathml-renderer/src/symbol.rs @@ -135,7 +135,7 @@ impl OrdLike { } OrdCategory::K => (Stretchy::Never, DelimiterSpacing::Zero), OrdCategory::KButUsedToBeB => (Stretchy::Never, DelimiterSpacing::NonZero), - _ => { + OrdCategory::D | OrdCategory::E | OrdCategory::I => { return None; } };