use crate::ir::*;
use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
pub fn parse(input: &str, front: &FrontMatter, fallback_title: &str) -> Vec<Slide> {
let mut opts = Options::empty();
opts.insert(Options::ENABLE_TABLES);
opts.insert(Options::ENABLE_STRIKETHROUGH);
opts.insert(Options::ENABLE_TASKLISTS);
opts.insert(Options::ENABLE_FOOTNOTES);
let preprocessed = preprocess(input);
let parser = Parser::new_ext(&preprocessed, opts);
let mut st = State::new(front, fallback_title);
for event in parser {
st.handle(event);
}
let mut slides = st.finish();
apply_layout_hints(&mut slides);
slides
}
fn apply_layout_hints(slides: &mut Vec<Slide>) {
for slide in slides {
let Some(hint) = slide.layout_hint.as_deref() else {
continue;
};
if hint != "image-left" && hint != "image-right" {
continue;
}
let image_count = slide
.blocks
.iter()
.filter(|b| matches!(b, Block::Image { .. }))
.count();
if image_count != 1 {
continue;
}
let mut image_block: Option<Block> = None;
let mut rest: Vec<Block> = Vec::with_capacity(slide.blocks.len());
for b in std::mem::take(&mut slide.blocks) {
if image_block.is_none() && matches!(b, Block::Image { .. }) {
image_block = Some(b);
} else {
rest.push(b);
}
}
let image = match image_block {
Some(b) => b,
None => continue,
};
let (left, right) = match hint {
"image-left" => (vec![image], rest),
_ => (rest, vec![image]),
};
slide.blocks = vec![Block::Columns { left, right }];
}
}
fn preprocess(input: &str) -> String {
let math_translated = crate::math::translate(input);
let mut out = String::with_capacity(math_translated.len());
let mut in_code = false;
for line in math_translated.lines() {
let trimmed = line.trim_start();
if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
in_code = !in_code;
out.push_str(line);
out.push('\n');
continue;
}
if !in_code && line.trim() == ":::" {
out.push_str("\n<!--md2any-col-->\n\n");
continue;
}
if !in_code {
out.push_str(&extract_image_attrs(line));
out.push('\n');
continue;
}
out.push_str(line);
out.push('\n');
}
out
}
fn extract_image_attrs(line: &str) -> String {
let bytes = line.as_bytes();
let mut out = String::with_capacity(line.len());
let mut copied = 0; let mut i = 0;
while i < bytes.len() {
if bytes[i] == b')' && bytes.get(i + 1) == Some(&b'{') {
if let Some(close_rel) = bytes[i + 2..].iter().position(|&b| b == b'}') {
let attr = &line[i + 2..i + 2 + close_rel];
if let Some(pct) = parse_width_pct(attr) {
out.push_str(&line[copied..=i]);
out.push_str(&format!("<!--md2any-imgwidth:{}-->", pct));
i = i + 2 + close_rel + 1;
copied = i;
continue;
}
}
}
i += 1;
}
out.push_str(&line[copied..]);
out
}
fn parse_width_pct(attr: &str) -> Option<u8> {
let attr = attr.trim();
let rest = attr.strip_prefix("width=")?.trim();
let n = rest.strip_suffix('%')?.trim().parse::<u32>().ok()?;
if (1..=100).contains(&n) {
Some(n as u8)
} else {
None
}
}
struct State<'a> {
front: &'a FrontMatter,
fallback_title: &'a str,
slides: Vec<Slide>,
current: Slide,
started_real_content: bool,
first_h1_consumed: bool,
runs: Vec<Run>,
bold: u32,
italic: u32,
strike: u32,
link: Option<String>,
heading_capture: Option<u8>,
list_stack: Vec<bool>,
list_items: Vec<ListItem>,
item_runs: Vec<Run>,
in_item: bool,
in_code: bool,
code_lang: Option<String>,
code_title: Option<String>,
code_buf: String,
in_blockquote: u32,
quote_paragraphs: Vec<Vec<Run>>,
in_table: bool,
in_thead: bool,
in_cell: bool,
table_headers: Vec<Vec<Run>>,
table_rows: Vec<Vec<Vec<Run>>>,
table_row: Vec<Vec<Run>>,
cell_runs: Vec<Run>,
in_image: bool,
image_src: String,
image_alt: String,
footnote_numbers: std::collections::HashMap<String, u32>,
footnote_defs: std::collections::HashMap<String, Vec<Run>>,
capturing_footnote: Option<String>,
slide_footnote_refs: Vec<Vec<String>>,
current_footnote_refs: Vec<String>,
}
impl<'a> State<'a> {
fn new(front: &'a FrontMatter, fallback_title: &'a str) -> Self {
let initial = if front.title.is_some() {
Slide {
kind: SlideKind::Title {
subtitle: front.subtitle.clone(),
author: front.author.clone(),
date: front.date.clone(),
},
title: front.title.clone().unwrap_or_else(|| fallback_title.into()),
blocks: Vec::new(),
notes: None,
bg_image: None,
layout_hint: None,
}
} else {
Slide {
kind: SlideKind::Content,
title: String::new(),
blocks: Vec::new(),
notes: None,
bg_image: None,
layout_hint: None,
}
};
State {
front,
fallback_title,
slides: Vec::new(),
current: initial,
started_real_content: front.title.is_some(),
first_h1_consumed: false,
runs: Vec::new(),
bold: 0,
italic: 0,
strike: 0,
link: None,
heading_capture: None,
list_stack: Vec::new(),
list_items: Vec::new(),
item_runs: Vec::new(),
in_item: false,
in_code: false,
code_lang: None,
code_title: None,
code_buf: String::new(),
in_blockquote: 0,
quote_paragraphs: Vec::new(),
in_table: false,
in_thead: false,
in_cell: false,
table_headers: Vec::new(),
table_rows: Vec::new(),
table_row: Vec::new(),
cell_runs: Vec::new(),
in_image: false,
image_src: String::new(),
image_alt: String::new(),
footnote_numbers: std::collections::HashMap::new(),
footnote_defs: std::collections::HashMap::new(),
capturing_footnote: None,
slide_footnote_refs: Vec::new(),
current_footnote_refs: Vec::new(),
}
}
fn current_attrs(&self) -> Run {
Run {
text: String::new(),
bold: self.bold > 0,
italic: self.italic > 0,
strike: self.strike > 0,
code: false,
link: self.link.clone(),
}
}
fn push_text(&mut self, text: &str, is_code: bool) {
if self.in_image {
self.image_alt.push_str(text);
return;
}
let mut run = self.current_attrs();
run.text = text.to_string();
run.code = is_code;
let sink: &mut Vec<Run> = if self.in_cell {
&mut self.cell_runs
} else if self.in_item {
&mut self.item_runs
} else {
&mut self.runs
};
if let Some(last) = sink.last_mut() {
if last.bold == run.bold
&& last.italic == run.italic
&& last.strike == run.strike
&& last.code == run.code
&& last.link == run.link
{
last.text.push_str(&run.text);
return;
}
}
sink.push(run);
}
fn flush_paragraph(&mut self) {
if self.runs.is_empty() {
return;
}
if self.capturing_footnote.is_some() {
return;
}
let runs = std::mem::take(&mut self.runs);
if self.in_blockquote > 0 {
self.quote_paragraphs.push(runs);
} else {
self.current.blocks.push(Block::Paragraph(runs));
self.started_real_content = true;
}
}
fn open_slide(&mut self, kind: SlideKind, title: String) {
let needs_flush = !self.current.title.is_empty()
|| !self.current.blocks.is_empty()
|| self.started_real_content;
if needs_flush {
self.slide_footnote_refs
.push(std::mem::take(&mut self.current_footnote_refs));
self.slides.push(std::mem::replace(
&mut self.current,
Slide {
kind: kind.clone(),
title,
blocks: Vec::new(),
notes: None,
bg_image: None,
layout_hint: None,
},
));
} else {
self.current.kind = kind;
self.current.title = title;
}
self.started_real_content = true;
}
fn handle(&mut self, event: Event) {
match event {
Event::Start(tag) => self.start_tag(tag),
Event::End(tag) => self.end_tag(tag),
Event::Text(t) => {
if self.in_code {
self.code_buf.push_str(&t);
} else if self.heading_capture.is_some() {
self.push_text(&t, false);
} else {
self.push_text(&t, false);
}
}
Event::Code(c) => {
self.push_text(&c, true);
}
Event::Html(c) | Event::InlineHtml(c) => {
let s = c.trim();
if s == "<!--md2any-col-->" {
self.flush_paragraph();
self.current.blocks.push(Block::ColumnBreak);
self.started_real_content = true;
} else if let Some(path) = extract_bg(s) {
self.current.bg_image = Some(path);
self.started_real_content = true;
} else if let Some(note) = extract_note(s) {
let existing = self.current.notes.take().unwrap_or_default();
let combined = if existing.is_empty() {
note
} else {
format!("{existing}\n\n{note}")
};
self.current.notes = Some(combined);
} else if let Some(name) = extract_layout(s) {
self.current.layout_hint = Some(name);
self.started_real_content = true;
} else if let Some(pct) = extract_img_width(s) {
for b in self.current.blocks.iter_mut().rev() {
if let Block::Image { width_pct, .. } = b {
*width_pct = Some(pct);
break;
}
}
}
}
Event::FootnoteReference(label) => {
let label_s = label.into_string();
let next_idx = (self.footnote_numbers.len() as u32) + 1;
let n = *self
.footnote_numbers
.entry(label_s.clone())
.or_insert(next_idx);
if !self.current_footnote_refs.contains(&label_s) {
self.current_footnote_refs.push(label_s);
}
self.push_text(&superscript(n), false);
}
Event::SoftBreak => {
self.push_text(" ", false);
}
Event::HardBreak => {
self.push_text(" ", false);
}
Event::Rule => {
self.flush_paragraph();
let title = if self.current.title.is_empty() {
self.fallback_title.to_string()
} else {
self.current.title.clone()
};
self.open_slide(SlideKind::Content, title);
}
Event::TaskListMarker(checked) => {
let mark = if checked { "☑ " } else { "☐ " };
self.push_text(mark, false);
}
}
}
fn start_tag(&mut self, tag: Tag) {
match tag {
Tag::Paragraph => {}
Tag::Heading { level, .. } => {
self.flush_paragraph();
let lvl = heading_to_u8(level);
self.heading_capture = Some(lvl);
self.runs.clear();
}
Tag::BlockQuote => {
self.flush_paragraph();
self.in_blockquote += 1;
self.quote_paragraphs = Vec::new();
}
Tag::CodeBlock(kind) => {
self.flush_paragraph();
self.in_code = true;
let (lang, title) = match kind {
CodeBlockKind::Fenced(info) => parse_fence_info(&info),
_ => (None, None),
};
self.code_lang = lang;
self.code_title = title;
self.code_buf.clear();
}
Tag::List(start) => {
self.flush_paragraph();
if self.in_item && !self.item_runs.is_empty() {
let runs = std::mem::take(&mut self.item_runs);
let level = (self.list_stack.len() as u8).saturating_sub(1);
let ordered = self.list_stack.last().copied().unwrap_or(false);
self.list_items.push(ListItem {
runs,
level,
ordered,
});
}
self.list_stack.push(start.is_some());
}
Tag::Item => {
self.in_item = true;
self.item_runs.clear();
}
Tag::Emphasis => self.italic += 1,
Tag::Strong => self.bold += 1,
Tag::Strikethrough => self.strike += 1,
Tag::Link { dest_url, .. } => {
self.link = Some(dest_url.to_string());
}
Tag::Image { dest_url, .. } => {
self.flush_paragraph();
self.in_image = true;
self.image_src = dest_url.to_string();
self.image_alt.clear();
}
Tag::Table(_) => {
self.flush_paragraph();
self.in_table = true;
self.table_headers.clear();
self.table_rows.clear();
}
Tag::FootnoteDefinition(label) => {
self.flush_paragraph();
let label_s = label.into_string();
let next_idx = (self.footnote_numbers.len() as u32) + 1;
self.footnote_numbers
.entry(label_s.clone())
.or_insert(next_idx);
self.capturing_footnote = Some(label_s);
self.runs.clear();
}
Tag::TableHead => {
self.in_thead = true;
}
Tag::TableRow => {
self.table_row.clear();
}
Tag::TableCell => {
self.in_cell = true;
self.cell_runs.clear();
}
_ => {}
}
}
fn end_tag(&mut self, tag: TagEnd) {
match tag {
TagEnd::Paragraph => {
self.flush_paragraph();
}
TagEnd::Heading(level) => {
let lvl = self.heading_capture.take().unwrap_or(heading_to_u8(level));
let runs = std::mem::take(&mut self.runs);
let title = runs_text(&runs);
if lvl == 1 {
if !self.first_h1_consumed && self.front.title.is_none() {
self.first_h1_consumed = true;
let subtitle = subtitle_from_runs(&runs);
self.open_slide(
SlideKind::Title {
subtitle,
author: self.front.author.clone(),
date: self.front.date.clone(),
},
title,
);
} else {
self.first_h1_consumed = true;
self.open_slide(SlideKind::Section, title);
}
} else if lvl == 2 {
self.open_slide(SlideKind::Content, title);
} else {
self.current
.blocks
.push(Block::Heading { level: lvl, runs });
self.started_real_content = true;
}
}
TagEnd::BlockQuote => {
self.flush_paragraph();
if self.in_blockquote > 0 {
self.in_blockquote -= 1;
}
if self.in_blockquote == 0 {
let paras = std::mem::take(&mut self.quote_paragraphs);
if !paras.is_empty() {
self.current.blocks.push(Block::Quote(paras));
self.started_real_content = true;
}
}
}
TagEnd::CodeBlock => {
let code = std::mem::take(&mut self.code_buf);
let lang = self.code_lang.take();
let title = self.code_title.take();
let lines: Vec<String> = code
.trim_end_matches('\n')
.split('\n')
.map(|s| s.to_string())
.collect();
let line_numbers = lines.len() > 5;
self.current.blocks.push(Block::CodeBlock {
lang,
title,
lines,
line_numbers,
});
self.started_real_content = true;
self.in_code = false;
}
TagEnd::List(_) => {
let _ordered = self.list_stack.pop().unwrap_or(false);
if self.list_stack.is_empty() && !self.list_items.is_empty() {
let items = std::mem::take(&mut self.list_items);
self.current.blocks.push(Block::List(items));
self.started_real_content = true;
}
}
TagEnd::Item => {
let runs = std::mem::take(&mut self.item_runs);
let level = (self.list_stack.len() as u8).saturating_sub(1);
let ordered = self.list_stack.last().copied().unwrap_or(false);
if !runs.is_empty() {
self.list_items.push(ListItem {
runs,
level,
ordered,
});
}
self.in_item = false;
}
TagEnd::Emphasis => self.italic = self.italic.saturating_sub(1),
TagEnd::Strong => self.bold = self.bold.saturating_sub(1),
TagEnd::Strikethrough => self.strike = self.strike.saturating_sub(1),
TagEnd::Link => {
self.link = None;
}
TagEnd::Image => {
if self.in_image {
let src = std::mem::take(&mut self.image_src);
let alt = std::mem::take(&mut self.image_alt);
self.in_image = false;
if !src.is_empty() {
self.current.blocks.push(Block::Image {
src,
alt,
width_pct: None,
});
self.started_real_content = true;
}
}
}
TagEnd::Table => {
self.in_table = false;
let headers = std::mem::take(&mut self.table_headers);
let rows = std::mem::take(&mut self.table_rows);
self.current.blocks.push(Block::Table { headers, rows });
self.started_real_content = true;
}
TagEnd::TableHead => {
self.in_thead = false;
self.table_headers = std::mem::take(&mut self.table_row);
}
TagEnd::TableRow => {
let row = std::mem::take(&mut self.table_row);
self.table_rows.push(row);
}
TagEnd::TableCell => {
let runs = std::mem::take(&mut self.cell_runs);
self.table_row.push(runs);
self.in_cell = false;
}
TagEnd::FootnoteDefinition => {
if let Some(label) = self.capturing_footnote.take() {
let runs = std::mem::take(&mut self.runs);
if !runs.is_empty() {
self.footnote_defs.insert(label, runs);
}
}
}
_ => {}
}
}
fn finish(mut self) -> Vec<Slide> {
self.flush_paragraph();
if !self.current.title.is_empty()
|| !self.current.blocks.is_empty()
|| self.slides.is_empty()
{
if self.current.title.is_empty() {
self.current.title = self.fallback_title.into();
}
self.slide_footnote_refs
.push(std::mem::take(&mut self.current_footnote_refs));
self.slides.push(self.current);
}
for (i, refs) in self.slide_footnote_refs.iter().enumerate() {
if refs.is_empty() {
continue;
}
let Some(slide) = self.slides.get_mut(i) else {
continue;
};
let mut items: Vec<ListItem> = Vec::new();
for label in refs {
let n = *self.footnote_numbers.get(label).unwrap_or(&0);
let body = self.footnote_defs.get(label).cloned().unwrap_or_default();
let mut runs: Vec<Run> = Vec::new();
runs.push(Run::plain(format!("{}. ", n)));
runs.extend(body);
items.push(ListItem {
runs,
level: 0,
ordered: false,
});
}
slide.blocks.push(Block::Footnotes(items));
}
self.slides
}
}
fn superscript(n: u32) -> String {
let s = n.to_string();
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
let sup = match ch {
'0' => '⁰',
'1' => '¹',
'2' => '²',
'3' => '³',
'4' => '⁴',
'5' => '⁵',
'6' => '⁶',
'7' => '⁷',
'8' => '⁸',
'9' => '⁹',
other => other,
};
out.push(sup);
}
out
}
fn heading_to_u8(l: HeadingLevel) -> u8 {
match l {
HeadingLevel::H1 => 1,
HeadingLevel::H2 => 2,
HeadingLevel::H3 => 3,
HeadingLevel::H4 => 4,
HeadingLevel::H5 => 5,
HeadingLevel::H6 => 6,
}
}
fn extract_bg(s: &str) -> Option<String> {
let s = s.trim();
if !s.starts_with("<!--") || !s.ends_with("-->") {
return None;
}
let inner = s[4..s.len() - 3].trim();
let lower = inner.to_ascii_lowercase();
let body = if let Some(b) = lower.strip_prefix("bg:") {
inner[inner.len() - b.len()..].trim()
} else if let Some(b) = lower.strip_prefix("background:") {
inner[inner.len() - b.len()..].trim()
} else {
return None;
};
if body.is_empty() {
None
} else {
Some(body.to_string())
}
}
fn extract_img_width(s: &str) -> Option<u8> {
let s = s.trim();
if !s.starts_with("<!--") || !s.ends_with("-->") {
return None;
}
let inner = s[4..s.len() - 3].trim();
let rest = inner.strip_prefix("md2any-imgwidth:")?.trim();
rest.parse::<u8>().ok().filter(|n| (1..=100).contains(n))
}
fn extract_layout(s: &str) -> Option<String> {
let s = s.trim();
if !s.starts_with("<!--") || !s.ends_with("-->") {
return None;
}
let inner = s[4..s.len() - 3].trim();
let lower = inner.to_ascii_lowercase();
let body = lower.strip_prefix("layout:")?.trim();
match body {
"image-left" | "image-right" => Some(body.to_string()),
_ => None,
}
}
fn extract_note(s: &str) -> Option<String> {
let s = s.trim();
if !s.starts_with("<!--") || !s.ends_with("-->") {
return None;
}
let inner = s[4..s.len() - 3].trim();
let lower = inner.to_ascii_lowercase();
let body = if lower.starts_with("speaker notes:") {
inner["speaker notes:".len()..].trim()
} else if lower.starts_with("notes:") {
inner["notes:".len()..].trim()
} else {
return None;
};
if body.is_empty() {
None
} else {
Some(body.to_string())
}
}
fn parse_fence_info(info: &str) -> (Option<String>, Option<String>) {
let info = info.trim();
if info.is_empty() {
return (None, None);
}
let mut parts = info.splitn(2, char::is_whitespace);
let lang = parts
.next()
.map(|s| s.to_string())
.filter(|s| !s.is_empty());
let title = parts
.next()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let title = title.map(|t| {
if let Some(stripped) = t.strip_prefix("title=") {
stripped.trim_matches('"').to_string()
} else {
t
}
});
(lang, title)
}
fn subtitle_from_runs(runs: &[Run]) -> Option<String> {
let text = runs_text(runs);
if let Some((_, after)) = text.split_once(": ") {
let s = after.trim();
if !s.is_empty() {
return Some(s.to_string());
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_image_attrs_preserves_non_ascii() {
let input = "Renders as: α — β with Δt₀ = 2L/c";
assert_eq!(extract_image_attrs(input), input);
}
#[test]
fn extract_image_attrs_rewrites_width_attribute() {
let out = extract_image_attrs("{width=50%} trailing");
assert!(out.contains("<!--md2any-imgwidth:50-->"));
assert!(out.contains("trailing"));
assert!(!out.contains("{width=50%}"));
}
#[test]
fn extract_image_attrs_ignores_unrelated_braces() {
let input = "function call(x){body}";
assert_eq!(extract_image_attrs(input), input);
}
#[test]
fn extract_image_attrs_with_attribute_and_unicode_after() {
let out = extract_image_attrs("{width=30%} then α");
assert!(out.contains("<!--md2any-imgwidth:30-->"));
assert!(out.contains("α"));
}
}