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
28 changes: 25 additions & 3 deletions src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,12 @@ use std::fmt::Display;
///
/// __Input__: `\text{Hello, world!}`
/// ```
/// # use pulldown_latex::event::{Event, Content};
/// [Event::Content(Content::Text("Hello, world!"))];
/// # use pulldown_latex::event::{Event, Content, Grouping};
/// [
/// Event::Begin(Grouping::Text),
/// Event::Content(Content::Text("Hello, world!")),
/// Event::End,
/// ];
/// ```
///
/// __Input__: `x^2_{\text{max}}`
Expand All @@ -46,8 +50,10 @@ use std::fmt::Display;
/// position: ScriptPosition::Right,
/// },
/// Event::Begin(Grouping::Normal),
/// Event::Begin(Grouping::Text),
/// Event::Content(Content::Text("max")),
/// Event::End,
/// Event::End,
/// Event::Content(Content::Ordinary {
/// content: 'x',
/// stretchy: false,
Expand Down Expand Up @@ -378,11 +384,27 @@ pub enum Grouping {
Multline,
/// The `split` environment of `LaTeX`.
Split,
/// A grouping induced by `\text{...}` (or other text-mode commands).
///
/// Inside this grouping, the content is parsed in text mode rather than math mode:
/// literal characters are coalesced into [`Content::Text`] runs, font commands like
/// `\textbf`, `\textit`, and `\emph` apply via [`StateChange::Font`], and a
/// whitelist of text-mode commands (`\LaTeX`, escaped specials, spacing, etc.) is
/// supported. Embedded `$...$` switches back into math mode via [`Grouping::InlineMath`].
Text,
/// A grouping induced by `$...$` appearing inside a [`Grouping::Text`].
///
/// This signals to the renderer that math-mode content follows, so it should drop
/// out of `<mtext>` and back into the normal math path.
InlineMath,
}

impl Grouping {
pub(crate) fn is_math_env(&self) -> bool {
!matches!(self, Self::Normal | Self::LeftRight(_, _))
!matches!(
self,
Self::Normal | Self::LeftRight(_, _) | Self::Text | Self::InlineMath
)
}
}

Expand Down
20 changes: 20 additions & 0 deletions src/mathml.rs
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,7 @@ where
self.writer.write_all(b"><mtr><mtd>")?;
EnvGrouping::Equation
}
Grouping::Text | Grouping::InlineMath => EnvGrouping::Normal,
};
self.env_stack.push(Environment::from(env_group));
Ok(())
Expand Down Expand Up @@ -587,6 +588,9 @@ where
match content {
Content::Text(text) => {
self.open_tag("mtext", None)?;
if let Some(mathvariant) = self.state().font.and_then(font_mathvariant) {
write!(self.writer, " mathvariant=\"{}\"", mathvariant)?;
}
self.writer.write_all(b">")?;
let trimmed = text.trim();
if text.starts_with(char::is_whitespace) {
Expand Down Expand Up @@ -1474,6 +1478,22 @@ where
MathmlWriter::new(parser, writer, config).write()
}

/// Map a [`Font`] to a MathML `mathvariant` value suitable for `<mtext>`.
///
/// Only the variants reachable from a text-mode font command (`\textbf`,
/// `\textit`/`\emph`/`\textsl`, `\textsf`, `\texttt`) need a direct mapping;
/// other variants fall back to no `mathvariant` attribute. `UpRight` matches
/// `<mtext>`'s default rendering, so it is also `None`.
fn font_mathvariant(font: Font) -> Option<&'static str> {
match font {
Font::Bold => Some("bold"),
Font::Italic => Some("italic"),
Font::SansSerif => Some("sans-serif"),
Font::Monospace => Some("monospace"),
_ => None,
}
}

fn write_escaped<W: io::Write>(writer: &mut W, s: &str) -> io::Result<()> {
let bytes = s.as_bytes();
let mut start = 0;
Expand Down
22 changes: 20 additions & 2 deletions src/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ impl<'store> Parser<'store> {
instruction_stack.push(Instruction::SubGroup {
content: input,
allowed_alignment_count: None,
text_mode: false,
});
let buffer = Vec::with_capacity(16);
Self {
Expand All @@ -102,17 +103,26 @@ impl<'store> Iterator for Parser<'store> {
_ => None,
})
.expect("there is something in the stack"))),
Some(Instruction::SubGroup { content, .. }) if content.trim_start().is_empty() => {
Some(Instruction::SubGroup {
content, text_mode, ..
}) if !*text_mode && content.trim_start().is_empty() => {
self.instruction_stack.pop();
self.next()
}
Some(Instruction::SubGroup {
content, text_mode, ..
}) if *text_mode && content.is_empty() => {
self.instruction_stack.pop();
self.next()
}
Some(Instruction::SubGroup {
content,
allowed_alignment_count,
..
text_mode,
}) => {
let state = ParserState {
allowed_alignment_count: allowed_alignment_count.as_mut(),
text_mode: *text_mode,
..Default::default()
};

Expand Down Expand Up @@ -211,6 +221,7 @@ impl<'b, 'store> InnerParser<'b, 'store> {
Instruction::SubGroup {
content: group,
allowed_alignment_count: None,
text_mode: self.state.text_mode,
},
Instruction::Event(Event::End),
]);
Expand All @@ -228,6 +239,11 @@ impl<'b, 'store> InnerParser<'b, 'store> {
///
/// [amsdocs]: https://mirror.its.dal.ca/ctan/macros/latex/required/amsmath/amsldoc.pdf
fn parse(&mut self) -> InnerResult<Option<(Event<'store>, ScriptDescriptor)>> {
if self.state.text_mode {
self.parse_text_element()?;
return Ok(None);
}

// 1. Parse the next token and output everything to the staging stack.
let original_content = self.content.trim_start();
let token = match lex::token(&mut self.content) {
Expand Down Expand Up @@ -418,6 +434,8 @@ enum Instruction<'a> {
SubGroup {
content: &'a str,
allowed_alignment_count: Option<AlignmentCount>,
/// Whether the substring should be parsed in text mode.
text_mode: bool,
},
}

Expand Down
41 changes: 41 additions & 0 deletions src/parser/lex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,47 @@ pub fn token<'a>(input: &mut &'a str) -> InnerResult<Token<'a>> {
}
}

/// Consume input up to (and past) the next unescaped `$` and return the prefix
/// without the trailing `$`. Brace-balanced regions and `\$` escapes are
/// honored; comments (`%...\n`) are skipped over.
pub fn until_unescaped_dollar<'a>(input: &mut &'a str) -> InnerResult<&'a str> {
let mut escaped = false;
let mut index = 0;
let bytes = input.as_bytes();
while index < bytes.len() {
match bytes[index] {
b'\\' if !escaped => {
escaped = true;
index += 1;
continue;
}
b'$' if !escaped => break,
b'%' if !escaped => {
let rest_pos = bytes[index..]
.iter()
.position(|&c| c == b'\n')
.unwrap_or(bytes.len() - index);
index += rest_pos;
}
b'{' if !escaped => {
let inner = group_content(&mut &input[index + 1..], GroupingKind::Normal)?;
index += inner.len() + 2;
escaped = false;
continue;
}
_ => {}
}
escaped = false;
index += 1;
}
if index >= bytes.len() {
return Err(ErrorKind::MathShift);
}
let (argument, rest) = input.split_at(index);
*input = &rest[1..];
Ok(argument)
}

pub fn color(color: &str) -> Option<(u8, u8, u8)> {
match color.strip_prefix('#') {
Some(color) if color.len() == 6 => {
Expand Down
Loading
Loading