//! A small Markdown subset renderer for the contents of `text` nodes. //! //! The spec says a text node holds Markdown but does not pin down a flavour, so //! this covers the parts that actually show up on canvases: headings, emphasis, //! inline code, fenced code, quotes, bullet/numbered/task lists, rules, links //! and images. Anything it does not recognise is rendered as plain text. use eframe::egui::{self, Color32, RichText, Ui}; use crate::theme::Palette; // --------------------------------------------------------------------------- // Model // --------------------------------------------------------------------------- #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct Style { pub bold: bool, pub italic: bool, pub code: bool, pub strike: bool, } /// A run of text sharing one style, optionally a link. #[derive(Debug, Clone, PartialEq)] pub struct Span { pub text: String, pub style: Style, /// Link target (`[text](target)`), or the URL of an autolink. pub link: Option, /// `true` for `![alt](src)`. pub image: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Marker { Bullet, Number(u64), Task(bool), } /// One cell of a table. pub type Cell = Vec; #[derive(Debug, Clone, PartialEq)] pub enum Block { Heading { level: u8, spans: Vec }, Paragraph(Vec), List { indent: usize, marker: Marker, spans: Vec }, Quote(Vec), Code { language: String, text: String }, Table { header: Vec, rows: Vec> }, Rule, } // --------------------------------------------------------------------------- // Block parsing // --------------------------------------------------------------------------- /// Splits Markdown source into renderable blocks. pub fn parse(source: &str) -> Vec { let mut blocks = Vec::new(); let mut paragraph: Vec = Vec::new(); let mut quote: Vec = Vec::new(); let mut lines = source.lines().peekable(); fn flush(buffer: &mut Vec, blocks: &mut Vec, quoted: bool) { if buffer.is_empty() { return; } let text = buffer.join(" "); buffer.clear(); let spans = parse_inline(&text); blocks.push(if quoted { Block::Quote(spans) } else { Block::Paragraph(spans) }); } while let Some(line) = lines.next() { let trimmed = line.trim(); if let Some(fence) = fence_of(trimmed) { flush(&mut paragraph, &mut blocks, false); flush(&mut quote, &mut blocks, true); let language = trimmed[fence.len()..].trim().to_owned(); let mut body = Vec::new(); for line in lines.by_ref() { if line.trim().starts_with(fence) { break; } body.push(line.to_owned()); } blocks.push(Block::Code { language, text: body.join("\n"), }); continue; } if trimmed.is_empty() { flush(&mut paragraph, &mut blocks, false); flush(&mut quote, &mut blocks, true); continue; } if is_rule(trimmed) { flush(&mut paragraph, &mut blocks, false); flush(&mut quote, &mut blocks, true); blocks.push(Block::Rule); continue; } if let Some((level, rest)) = heading_of(trimmed) { flush(&mut paragraph, &mut blocks, false); flush(&mut quote, &mut blocks, true); blocks.push(Block::Heading { level, spans: parse_inline(rest), }); continue; } if let Some(rest) = trimmed.strip_prefix('>') { flush(&mut paragraph, &mut blocks, false); quote.push(rest.trim_start().to_owned()); continue; } // A pipe table: a header row, a `| --- |` separator, then the body. if trimmed.contains('|') && lines.peek().is_some_and(|next| is_separator_row(next)) { flush(&mut paragraph, &mut blocks, false); flush(&mut quote, &mut blocks, true); let header = split_row(trimmed); lines.next(); let mut rows = Vec::new(); while let Some(row) = lines.peek() { if !row.contains('|') || row.trim().is_empty() { break; } rows.push(split_row(row.trim())); lines.next(); } blocks.push(Block::Table { header, rows }); continue; } if let Some((indent, marker, rest)) = list_item_of(line) { flush(&mut paragraph, &mut blocks, false); flush(&mut quote, &mut blocks, true); blocks.push(Block::List { indent, marker, spans: parse_inline(rest), }); continue; } flush(&mut quote, &mut blocks, true); paragraph.push(trimmed.to_owned()); } flush(&mut paragraph, &mut blocks, false); flush(&mut quote, &mut blocks, true); blocks } fn fence_of(line: &str) -> Option<&'static str> { if line.starts_with("```") { Some("```") } else if line.starts_with("~~~") { Some("~~~") } else { None } } fn is_rule(line: &str) -> bool { let compact: String = line.chars().filter(|c| !c.is_whitespace()).collect(); compact.len() >= 3 && (compact.chars().all(|c| c == '-') || compact.chars().all(|c| c == '*') || compact.chars().all(|c| c == '_')) } /// `| --- | :--: |`, the line that turns the row above it into a table header. fn is_separator_row(line: &str) -> bool { let line = line.trim(); if !line.contains('|') { return false; } let cells = split_cells(line); !cells.is_empty() && cells.iter().all(|cell| { let body = cell.trim().trim_start_matches(':').trim_end_matches(':'); !body.is_empty() && body.chars().all(|c| c == '-') }) } fn split_cells(line: &str) -> Vec<&str> { line.trim() .trim_start_matches('|') .trim_end_matches('|') .split('|') .map(str::trim) .collect() } fn split_row(line: &str) -> Vec { split_cells(line).into_iter().map(parse_inline).collect() } fn heading_of(line: &str) -> Option<(u8, &str)> { let hashes = line.chars().take_while(|c| *c == '#').count(); if (1..=6).contains(&hashes) { let rest = &line[hashes..]; // ATX headings need a space after the hashes. rest.strip_prefix(' ').map(|rest| (hashes as u8, rest.trim())) } else { None } } fn list_item_of(line: &str) -> Option<(usize, Marker, &str)> { let indent_chars = line.len() - line.trim_start().len(); let indent = indent_chars / 2; let trimmed = line.trim_start(); let (marker, rest) = if let Some(rest) = trimmed .strip_prefix("- ") .or_else(|| trimmed.strip_prefix("* ")) .or_else(|| trimmed.strip_prefix("+ ")) { let rest_trimmed = rest.trim_start(); let lowered = rest_trimmed.to_ascii_lowercase(); if let Some(task) = lowered.strip_prefix("[ ] ") { (Marker::Task(false), &rest_trimmed[rest_trimmed.len() - task.len()..]) } else if let Some(task) = lowered .strip_prefix("[x] ") .or_else(|| lowered.strip_prefix("[/] ")) { (Marker::Task(true), &rest_trimmed[rest_trimmed.len() - task.len()..]) } else { (Marker::Bullet, rest) } } else { let digits: String = trimmed.chars().take_while(char::is_ascii_digit).collect(); if digits.is_empty() { return None; } let rest = &trimmed[digits.len()..]; let rest = rest.strip_prefix(". ").or_else(|| rest.strip_prefix(") "))?; (Marker::Number(digits.parse().ok()?), rest) }; Some((indent, marker, rest.trim_start())) } // --------------------------------------------------------------------------- // Inline parsing // --------------------------------------------------------------------------- /// Parses emphasis, code, links and images inside one block of text. pub fn parse_inline(text: &str) -> Vec { let mut spans = Vec::new(); parse_inline_into(text, Style::default(), &mut spans); spans.retain(|s| !s.text.is_empty()); spans } fn parse_inline_into(text: &str, style: Style, out: &mut Vec) { let mut plain = String::new(); let mut rest = text; macro_rules! flush { () => { if !plain.is_empty() { out.push(Span { text: std::mem::take(&mut plain), style, link: None, image: false, }); } }; } while !rest.is_empty() { // Backslash escapes. if let Some(tail) = rest.strip_prefix('\\') { let mut chars = tail.chars(); if let Some(escaped) = chars.next() { plain.push(escaped); rest = chars.as_str(); continue; } } // Inline code takes precedence and is never parsed further. if let Some((inner, tail)) = delimited(rest, "`") { flush!(); out.push(Span { text: inner.to_owned(), style: Style { code: true, ..style }, link: None, image: false, }); rest = tail; continue; } if let Some((inner, tail)) = delimited(rest, "**").or_else(|| delimited(rest, "__")) { flush!(); parse_inline_into(inner, Style { bold: true, ..style }, out); rest = tail; continue; } if let Some((inner, tail)) = delimited(rest, "~~") { flush!(); parse_inline_into(inner, Style { strike: true, ..style }, out); rest = tail; continue; } if let Some((inner, tail)) = delimited(rest, "*").or_else(|| delimited(rest, "_")) { flush!(); parse_inline_into(inner, Style { italic: true, ..style }, out); rest = tail; continue; } if let Some((label, target, tail, image)) = link_at(rest) { flush!(); let mut link_spans = Vec::new(); parse_inline_into(label, style, &mut link_spans); if link_spans.is_empty() { link_spans.push(Span { text: target.clone(), style, link: None, image: false, }); } for mut span in link_spans { span.link = Some(target.clone()); span.image = image; out.push(span); } rest = tail; continue; } if let Some((url, tail)) = autolink_at(rest) { flush!(); out.push(Span { text: url.clone(), style, link: Some(url), image: false, }); rest = tail; continue; } let mut chars = rest.chars(); if let Some(c) = chars.next() { plain.push(c); } rest = chars.as_str(); } flush!(); } /// If `text` opens with `delim`, returns the content up to the matching `delim` /// and the remainder after it. fn delimited<'a>(text: &'a str, delim: &str) -> Option<(&'a str, &'a str)> { let after_open = text.strip_prefix(delim)?; // `**bold**` must not be mistaken for the start of `*italic*`. if delim.len() == 1 && after_open.starts_with(delim) { return None; } let end = after_open.find(delim)?; let inner = &after_open[..end]; if inner.is_empty() || inner.starts_with(char::is_whitespace) { return None; } Some((inner, &after_open[end + delim.len()..])) } /// Parses `[label](target)` or `![alt](src)` at the start of `text`. fn link_at(text: &str) -> Option<(&str, String, &str, bool)> { let (image, body) = match text.strip_prefix('!') { Some(body) if body.starts_with('[') => (true, body), _ => (false, text), }; let after_open = body.strip_prefix('[')?; let close = after_open.find("](")?; let label = &after_open[..close]; let after_label = &after_open[close + 2..]; let end = after_label.find(')')?; let target = after_label[..end].trim().to_owned(); if target.is_empty() { return None; } Some((label, target, &after_label[end + 1..], image)) } /// Parses a bare `http(s)://…` URL at the start of `text`. fn autolink_at(text: &str) -> Option<(String, &str)> { if !(text.starts_with("http://") || text.starts_with("https://")) { return None; } let end = text .find(|c: char| c.is_whitespace() || matches!(c, ')' | ']' | '>' | '"')) .unwrap_or(text.len()); let url = text[..end].trim_end_matches(['.', ',', ';', ':', '!']); Some((url.to_owned(), &text[url.len()..])) } // --------------------------------------------------------------------------- // Rendering // --------------------------------------------------------------------------- const HEADING_SCALE: [f32; 6] = [1.7, 1.45, 1.25, 1.12, 1.0, 0.95]; /// Draws the blocks and returns the target of a link the user clicked. pub fn render(ui: &mut Ui, blocks: &[Block], palette: &Palette, base_size: f32) -> Option { let mut activated = None; let mut table_index = 0usize; ui.spacing_mut().item_spacing.y = base_size * 0.35; for block in blocks { match block { Block::Heading { level, spans } => { let size = base_size * HEADING_SCALE[(*level as usize - 1).min(5)]; if let Some(link) = spans_ui(ui, spans, palette, size, true) { activated = Some(link); } } Block::Paragraph(spans) => { if let Some(link) = spans_ui(ui, spans, palette, base_size, false) { activated = Some(link); } } Block::List { indent, marker, spans, } => { ui.horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing.x = 0.0; ui.add_space(base_size * (0.4 + 1.2 * *indent as f32)); let bullet = match marker { Marker::Bullet => "• ".to_owned(), Marker::Number(n) => format!("{n}. "), Marker::Task(true) => "☑ ".to_owned(), Marker::Task(false) => "☐ ".to_owned(), }; ui.label( RichText::new(bullet) .size(base_size) .color(palette.text_muted), ); if let Some(link) = spans_inline(ui, spans, palette, base_size, false) { activated = Some(link); } }); } Block::Quote(spans) => { let stripe = palette.text_muted.gamma_multiply(0.6); egui::Frame::NONE .inner_margin(egui::Margin { left: (base_size * 0.7) as i8, ..Default::default() }) .show(ui, |ui| { let top = ui.cursor().min; let result = spans_ui(ui, spans, palette, base_size, false); let bottom = ui.min_rect().max.y; let x = top.x - base_size * 0.45; ui.painter().line_segment( [egui::pos2(x, top.y), egui::pos2(x, bottom)], egui::Stroke::new(2.0, stripe), ); if let Some(link) = result { activated = Some(link); } }); } Block::Code { text, .. } => { egui::Frame::NONE .fill(palette.surface_muted) .corner_radius(4.0) .inner_margin(egui::Margin::symmetric( (base_size * 0.5) as i8, (base_size * 0.35) as i8, )) .show(ui, |ui| { ui.label( RichText::new(text) .monospace() .size(base_size * 0.92) .color(palette.text), ); }); } Block::Table { header, rows } => { table_index += 1; egui::Grid::new(("markdown-table", ui.id(), table_index)) .striped(true) .spacing([base_size * 0.8, base_size * 0.25]) .show(ui, |ui| { for cell in header { ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 0.0; spans_inline(ui, cell, palette, base_size, true); }); } ui.end_row(); for row in rows { for cell in row { ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 0.0; if let Some(link) = spans_inline(ui, cell, palette, base_size, false) { activated = Some(link); } }); } ui.end_row(); } }); } Block::Rule => { let stroke = egui::Stroke::new(1.0, palette.border); let width = ui.available_width(); let (rect, _) = ui.allocate_exact_size( egui::vec2(width, base_size * 0.6), egui::Sense::hover(), ); ui.painter().line_segment( [ egui::pos2(rect.left(), rect.center().y), egui::pos2(rect.right(), rect.center().y), ], stroke, ); } } } activated } fn spans_ui( ui: &mut Ui, spans: &[Span], palette: &Palette, size: f32, heading: bool, ) -> Option { ui.horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing.x = 0.0; spans_inline(ui, spans, palette, size, heading) }) .inner } fn spans_inline( ui: &mut Ui, spans: &[Span], palette: &Palette, size: f32, heading: bool, ) -> Option { let mut activated = None; for span in spans { let mut text = RichText::new(&span.text).size(size); if span.style.bold || heading { text = text.strong(); } if span.style.italic { text = text.italics(); } if span.style.strike { text = text.strikethrough(); } if span.style.code { text = text .monospace() .size(size * 0.92) .background_color(palette.surface_muted); } match &span.link { Some(target) => { let label = if span.image { RichText::new(format!("[image] {}", span.text)).size(size) } else { text } .color(link_color(palette)); if ui.link(label).on_hover_text(target).clicked() { activated = Some(target.clone()); } } None => { ui.label(text.color(palette.text)); } } } activated } fn link_color(palette: &Palette) -> Color32 { palette.accent } #[cfg(test)] mod tests { use super::*; #[test] fn parses_headings_and_paragraphs() { let blocks = parse("# Title\n\nSome text\nwrapped over lines.\n"); assert_eq!(blocks.len(), 2); assert!(matches!(blocks[0], Block::Heading { level: 1, .. })); let Block::Paragraph(spans) = &blocks[1] else { panic!("expected a paragraph"); }; assert_eq!(spans[0].text, "Some text wrapped over lines."); } #[test] fn hash_without_a_space_is_not_a_heading() { assert!(matches!(parse("#tag")[0], Block::Paragraph(_))); } #[test] fn parses_emphasis() { let spans = parse_inline("a **bold** and *italic* and `code` and ~~gone~~"); let styled: Vec<_> = spans .iter() .map(|s| (s.text.as_str(), s.style)) .filter(|(_, style)| *style != Style::default()) .collect(); assert_eq!(styled[0].0, "bold"); assert!(styled[0].1.bold); assert!(styled[1].1.italic); assert!(styled[2].1.code); assert!(styled[3].1.strike); } #[test] fn parses_links_images_and_autolinks() { let spans = parse_inline("see [the spec](https://jsoncanvas.org) now"); let link = spans.iter().find(|s| s.link.is_some()).unwrap(); assert_eq!(link.text, "the spec"); assert_eq!(link.link.as_deref(), Some("https://jsoncanvas.org")); let spans = parse_inline("![a picture](img/pic.png)"); assert!(spans[0].image); assert_eq!(spans[0].link.as_deref(), Some("img/pic.png")); let spans = parse_inline("go to https://example.com."); let link = spans.iter().find(|s| s.link.is_some()).unwrap(); assert_eq!(link.link.as_deref(), Some("https://example.com")); } #[test] fn parses_lists_including_tasks() { let blocks = parse("- one\n- [ ] todo\n- [x] done\n1. first\n - nested"); let markers: Vec = blocks .iter() .filter_map(|b| match b { Block::List { marker, .. } => Some(*marker), _ => None, }) .collect(); assert_eq!( markers, vec![ Marker::Bullet, Marker::Task(false), Marker::Task(true), Marker::Number(1), Marker::Bullet ] ); let Block::List { indent, spans, .. } = &blocks[4] else { panic!("expected a list item"); }; assert_eq!(*indent, 1); assert_eq!(spans[0].text, "nested"); } #[test] fn parses_fenced_code_and_rules() { let blocks = parse("---\n```rust\nlet x = 1;\n```\n"); assert!(matches!(blocks[0], Block::Rule)); let Block::Code { language, text } = &blocks[1] else { panic!("expected code"); }; assert_eq!(language, "rust"); assert_eq!(text, "let x = 1;"); } #[test] fn parses_pipe_tables() { let blocks = parse("| Key | What it does |\n| --- | :---: |\n| `a` | first |\n| b | second |\n\nafter"); let Block::Table { header, rows } = &blocks[0] else { panic!("expected a table, got {blocks:?}"); }; assert_eq!(header.len(), 2); assert_eq!(header[1][0].text, "What it does"); assert_eq!(rows.len(), 2); assert!(rows[0][0][0].style.code); assert_eq!(rows[1][1][0].text, "second"); // The paragraph after the table is still its own block. assert!(matches!(blocks[1], Block::Paragraph(_))); } #[test] fn a_lone_pipe_is_not_a_table() { assert!(matches!(parse("a | b")[0], Block::Paragraph(_))); assert!(!is_separator_row("| not a separator |")); assert!(is_separator_row("|---|:--:|")); } #[test] fn parses_quotes() { let blocks = parse("> quoted\n> again"); let Block::Quote(spans) = &blocks[0] else { panic!("expected a quote"); }; assert_eq!(spans[0].text, "quoted again"); } #[test] fn escapes_are_honoured() { let spans = parse_inline("literal \\*stars\\*"); assert_eq!(spans.len(), 1); assert_eq!(spans[0].text, "literal *stars*"); } }