diff --git a/src/event.rs b/src/event.rs index a3270c5..5cceae2 100644 --- a/src/event.rs +++ b/src/event.rs @@ -24,8 +24,8 @@ use std::fmt::Display; /// by [`Event::Begin`] and [`Event::End`], an [`Event::Visual`] or an [`Event::Script`] element, /// an [`Event::Space`], or an [`Event::StateChange`]. /// -/// [`Event::Alignment`]s, and [`Event::NewLine`]s are not considered elements, and must never -/// occur when an element is expected. +/// [`EnvironmentFlow::Alignment`]s, and [`EnvironmentFlow::NewLine`]s are not considered elements, +/// and must never occur when an element is expected. /// /// ### Examples /// @@ -107,6 +107,9 @@ pub enum Content<'a> { /// A function identifier, such as `sin`, `lim`, or a custom function with /// `\operatorname{arccotan}`. Function(&'a str), + /// A multi-character variable identifier, e.g., the contents of `\mathrm{total}` or + /// `\mathit{example}` when the argument is a run of alphabetic characters. + Identifier(&'a str), /// A variable identifier, such as `x`, `\theta`, `\aleph`, etc., and other stuff that do not have /// any spacing around them. This includes things that normally go in under and overscripts /// which may be stretchy, e.g., `→`, `‾`, etc. diff --git a/src/mathml.rs b/src/mathml.rs index 32e47d4..1899967 100644 --- a/src/mathml.rs +++ b/src/mathml.rs @@ -670,6 +670,33 @@ where Ok(()) } + Content::Identifier(s) => { + self.open_tag("mi", None)?; + self.writer.write_all(b">")?; + let font = self.state().font; + let math_style = self.config.math_style; + let buf = &mut [0u8; 4]; + s.chars().try_for_each(|c| { + let mapped = match font { + // `BoldSymbol` resolves per-character to `Bold` for + // upright glyphs and to `BoldItalic` otherwise, matching + // the dispatch performed on the `Ordinary` path. + Some(Font::BoldSymbol) => { + let resolved = if math_style.should_be_upright(c) { + Font::Bold + } else { + Font::BoldItalic + }; + resolved.map_char(c) + } + Some(font) => font.map_char(c), + None => c, + }; + self.writer.write_all(mapped.encode_utf8(buf).as_bytes()) + })?; + self.set_previous_atom(Atom::Ord); + self.writer.write_all(b"") + } Content::Ordinary { content, stretchy } => { if stretchy { self.writer.write_all(b"")?; diff --git a/src/parser.rs b/src/parser.rs index 8d07dc5..a3fa689 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -528,7 +528,7 @@ struct ExpansionSpan<'a> { #[cfg(test)] mod tests { - use crate::event::{Content, DelimiterType, Dimension, DimensionUnit, RelationContent, Visual}; + use crate::event::{Content, DelimiterType, Font, RelationContent, StateChange, Visual}; use super::*; @@ -843,6 +843,85 @@ mod tests { ); } + #[test] + fn multi_letter_identifier_in_font_group() { + let store = Storage::new(); + let parser = Parser::new(r"\mathit{example}", &store); + let events = parser.collect::, ParserError>>().unwrap(); + + assert_eq!( + events, + vec![ + Event::Begin(Grouping::Normal), + Event::StateChange(StateChange::Font(Some(Font::Italic))), + Event::Content(Content::Identifier("example")), + Event::End, + ] + ); + } + + #[test] + fn multi_letter_identifier_in_mathrm() { + let store = Storage::new(); + let parser = Parser::new(r"\mathrm{total}", &store); + let events = parser.collect::, ParserError>>().unwrap(); + + assert_eq!( + events, + vec![ + Event::Begin(Grouping::Normal), + Event::StateChange(StateChange::Font(Some(Font::UpRight))), + Event::Content(Content::Identifier("total")), + Event::End, + ] + ); + } + + #[test] + fn single_letter_font_group_unchanged() { + // A single-character argument should still go through the per-char path. + let store = Storage::new(); + let parser = Parser::new(r"\mathit{x}", &store); + let events = parser.collect::, ParserError>>().unwrap(); + + assert_eq!( + events, + vec![ + Event::Begin(Grouping::Normal), + Event::StateChange(StateChange::Font(Some(Font::Italic))), + Event::Content(Content::Ordinary { + content: 'x', + stretchy: false, + }), + Event::End, + ] + ); + } + + #[test] + fn font_group_with_nonalpha_falls_back() { + // A group with non-alphabetic chars must not be collapsed into an Identifier. + let store = Storage::new(); + let parser = Parser::new(r"\mathit{x+y}", &store); + let events = parser.collect::, ParserError>>().unwrap(); + + assert!(!events + .iter() + .any(|e| matches!(e, Event::Content(Content::Identifier(_))))); + } + + #[test] + fn font_group_with_nonalpha_first_char_falls_back() { + // Exercises the early-return path when the first char is not alphabetic. + let store = Storage::new(); + let parser = Parser::new(r"\mathit{+x}", &store); + let events = parser.collect::, ParserError>>().unwrap(); + + assert!(!events + .iter() + .any(|e| matches!(e, Event::Content(Content::Identifier(_))))); + } + #[test] fn expansions_in_groups() { let store = Storage::new(); diff --git a/src/parser/primitives.rs b/src/parser/primitives.rs index c42c027..2d2b157 100644 --- a/src/parser/primitives.rs +++ b/src/parser/primitives.rs @@ -742,6 +742,7 @@ impl<'b, 'store> InnerParser<'b, 'store> { "strut" => E::Space { width: None, height: Some(Dimension::new(1.0, DimensionUnit::Em)), + depth: None, }, "~" | "nobreakspace" => E::Content(C::Text(" ")), // Variable spacing @@ -2070,6 +2071,9 @@ impl<'b, 'store> InnerParser<'b, 'store> { Token::Character(c) => self.handle_char_token(c)?, }; } + Argument::Group(group) if is_alphabetic_identifier(group) => { + self.buffer.push(I::Event(E::Content(C::Identifier(group)))); + } Argument::Group(group) => { self.buffer.push(I::SubGroup { content: group, @@ -2299,6 +2303,23 @@ fn binary(op: char) -> E<'static> { }) } +/// Whether the contents of a font-modifier group should be treated as a single +/// multi-letter identifier. Requires at least two ASCII alphabetic characters +/// and nothing else. +fn is_alphabetic_identifier(s: &str) -> bool { + let mut chars = s.chars(); + let Some(first) = chars.next() else { + return false; + }; + if !first.is_ascii_alphabetic() { + return false; + } + let Some(second) = chars.next() else { + return false; + }; + second.is_ascii_alphabetic() && chars.all(|c| c.is_ascii_alphabetic()) +} + /// Math atom classes used by the `\math` family of commands. /// /// These mirror the TeXbook's eight atom classes and are used by