Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
///
Expand Down Expand Up @@ -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.
Expand Down
27 changes: 27 additions & 0 deletions src/mathml.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"</mi>")
}
Content::Ordinary { content, stretchy } => {
if stretchy {
self.writer.write_all(b"<mo stretchy=\"true\">")?;
Expand Down
81 changes: 80 additions & 1 deletion src/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::*;

Expand Down Expand Up @@ -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::<Result<Vec<_>, 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::<Result<Vec<_>, 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::<Result<Vec<_>, 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::<Result<Vec<_>, 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::<Result<Vec<_>, ParserError>>().unwrap();

assert!(!events
.iter()
.any(|e| matches!(e, Event::Content(Content::Identifier(_)))));
}

#[test]
fn expansions_in_groups() {
let store = Storage::new();
Expand Down
21 changes: 21 additions & 0 deletions src/parser/primitives.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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("&nbsp;")),
// Variable spacing
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<class>` family of commands.
///
/// These mirror the TeXbook's eight atom classes and are used by
Expand Down
Loading