Files
jsoncanvas-desktop/src/markdown.rs
T
2026-09-10 11:49:20 +02:00

795 lines
25 KiB
Rust

//! 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<String>,
/// `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<Span>;
#[derive(Debug, Clone, PartialEq)]
pub enum Block {
Heading {
level: u8,
spans: Vec<Span>,
},
Paragraph(Vec<Span>),
List {
indent: usize,
marker: Marker,
spans: Vec<Span>,
},
Quote(Vec<Span>),
Code {
language: String,
text: String,
},
Table {
header: Vec<Cell>,
rows: Vec<Vec<Cell>>,
},
Rule,
}
// ---------------------------------------------------------------------------
// Block parsing
// ---------------------------------------------------------------------------
/// Splits Markdown source into renderable blocks.
pub fn parse(source: &str) -> Vec<Block> {
let mut blocks = Vec::new();
let mut paragraph: Vec<String> = Vec::new();
let mut quote: Vec<String> = Vec::new();
let mut lines = source.lines().peekable();
fn flush(buffer: &mut Vec<String>, blocks: &mut Vec<Block>, 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<Cell> {
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<Span> {
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<Span>) {
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<String> {
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<String> {
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<String> {
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<Marker> = 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*");
}
}