use pulldown_cmark::{Event, Parser, Tag, TagEnd};
use crate::support::canvas::Canvas;
use crate::support::color::Color;
use crate::support::font::Font;
use crate::support::point::Point;
#[derive(Debug, Clone, PartialEq)]
pub struct StyledRun {
pub text: String,
pub bold: bool,
pub italic: bool,
pub monospace: bool,
}
pub(crate) fn run_font(run: &StyledRun) -> Font {
let mut font = if run.monospace {
Font::monospace()
} else {
Font::sans_serif()
};
if run.bold {
font = font.bold();
}
if run.italic {
font = font.italic();
}
font
}
pub fn markdown_to_runs(source: &str) -> Vec<Vec<StyledRun>> {
let mut lines: Vec<Vec<StyledRun>> = vec![Vec::new()];
let mut bold_depth = 0usize;
let mut italic_depth = 0usize;
let mut code_depth = 0usize;
let push_text =
|lines: &mut Vec<Vec<StyledRun>>, text: &str, bold: bool, italic: bool, monospace: bool| {
let mut parts = text.split('\n');
if let Some(first) = parts.next() {
if !first.is_empty() {
lines.last_mut().unwrap().push(StyledRun {
text: first.to_string(),
bold,
italic,
monospace,
});
}
}
for part in parts {
lines.push(Vec::new());
if !part.is_empty() {
lines.last_mut().unwrap().push(StyledRun {
text: part.to_string(),
bold,
italic,
monospace,
});
}
}
};
for event in Parser::new(source) {
match event {
Event::Text(text) => push_text(
&mut lines,
&text,
bold_depth > 0,
italic_depth > 0,
code_depth > 0,
),
Event::Code(text) => {
push_text(&mut lines, &text, bold_depth > 0, italic_depth > 0, true)
}
Event::Start(Tag::Strong) => bold_depth += 1,
Event::End(TagEnd::Strong) => bold_depth = bold_depth.saturating_sub(1),
Event::Start(Tag::Emphasis) => italic_depth += 1,
Event::End(TagEnd::Emphasis) => italic_depth = italic_depth.saturating_sub(1),
Event::Start(Tag::CodeBlock(_)) => code_depth += 1,
Event::End(TagEnd::CodeBlock) => {
code_depth = code_depth.saturating_sub(1);
lines.push(Vec::new());
}
Event::Start(Tag::Heading { .. }) => bold_depth += 1,
Event::End(TagEnd::Heading(_)) => {
bold_depth = bold_depth.saturating_sub(1);
lines.push(Vec::new());
}
Event::Start(Tag::Item) => {
lines.last_mut().unwrap().push(StyledRun {
text: "\u{2022} ".to_string(),
bold: false,
italic: false,
monospace: false,
});
}
Event::End(TagEnd::Item) => lines.push(Vec::new()),
Event::End(TagEnd::Paragraph) => lines.push(Vec::new()),
Event::SoftBreak => push_text(
&mut lines,
" ",
bold_depth > 0,
italic_depth > 0,
code_depth > 0,
),
Event::HardBreak => lines.push(Vec::new()),
_ => {}
}
}
while lines.len() > 1 && lines.last().is_some_and(Vec::is_empty) {
lines.pop();
}
lines
}
pub fn wrap_runs(
canvas: &mut Canvas,
lines: &[Vec<StyledRun>],
max_width: f32,
) -> Vec<Vec<StyledRun>> {
let mut out = Vec::new();
for line in lines {
if line.is_empty() {
out.push(Vec::new());
continue;
}
let mut current: Vec<StyledRun> = Vec::new();
let mut current_width = 0.0f32;
for run in line {
canvas.font(run_font(run));
for (word_index, word) in run.text.split(' ').enumerate() {
let piece = if word_index > 0 {
format!(" {word}")
} else {
word.to_string()
};
if piece.is_empty() {
continue;
}
let piece_width = canvas.text_width(&piece);
if current_width > 0.0 && current_width + piece_width > max_width {
out.push(std::mem::take(&mut current));
let trimmed = piece.trim_start().to_string();
let trimmed_width = canvas.text_width(&trimmed);
append_run(&mut current, trimmed, run);
current_width = trimmed_width;
} else {
append_run(&mut current, piece, run);
current_width += piece_width;
}
}
}
out.push(current);
}
out
}
fn append_run(current: &mut Vec<StyledRun>, text: String, style: &StyledRun) {
if text.is_empty() {
return;
}
if let Some(last) = current.last_mut() {
if last.bold == style.bold
&& last.italic == style.italic
&& last.monospace == style.monospace
{
last.text.push_str(&text);
return;
}
}
current.push(StyledRun {
text,
bold: style.bold,
italic: style.italic,
monospace: style.monospace,
});
}
pub fn draw_runs(
canvas: &mut Canvas,
lines: &[Vec<StyledRun>],
origin: Point,
line_height: f32,
color: Color,
) {
canvas.fill_style(color);
let mut y = origin.y;
for line in lines {
let mut x = origin.x;
for run in line {
canvas.font(run_font(run));
canvas.fill_text(&run.text, Point::new(x, y));
x += canvas.text_width(&run.text);
}
y += line_height;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plain_text_is_a_single_unstyled_run() {
let lines = markdown_to_runs("hello world");
assert_eq!(lines.len(), 1);
assert_eq!(
lines[0],
vec![StyledRun {
text: "hello world".to_string(),
bold: false,
italic: false,
monospace: false
}]
);
}
#[test]
fn bold_italic_and_inline_code_produce_distinct_runs() {
let lines = markdown_to_runs("**bold** and *italic* and `code`");
assert_eq!(lines.len(), 1);
assert_eq!(
lines[0],
vec![
StyledRun {
text: "bold".to_string(),
bold: true,
italic: false,
monospace: false
},
StyledRun {
text: " and ".to_string(),
bold: false,
italic: false,
monospace: false
},
StyledRun {
text: "italic".to_string(),
bold: false,
italic: true,
monospace: false
},
StyledRun {
text: " and ".to_string(),
bold: false,
italic: false,
monospace: false
},
StyledRun {
text: "code".to_string(),
bold: false,
italic: false,
monospace: true
},
]
);
}
#[test]
fn a_code_block_preserves_its_text_without_treating_markdown_syntax_inside_it_specially() {
let lines = markdown_to_runs("```\nlet x = **not bold**;\n```");
let code_text: String = lines
.iter()
.flat_map(|line| line.iter().map(|r| r.text.as_str()))
.collect::<Vec<_>>()
.join("\n");
assert!(
code_text.contains("let x = **not bold**;"),
"code block text was mangled: {code_text:?}"
);
assert!(
lines
.iter()
.flatten()
.all(|r| r.monospace || r.text.trim().is_empty()),
"expected every non-empty run inside the code block to be monospace"
);
}
#[test]
fn a_bullet_list_prefixes_each_item_with_a_bullet_on_its_own_line() {
let lines = markdown_to_runs("- first\n- second");
let rendered: Vec<String> = lines
.iter()
.map(|line| line.iter().map(|r| r.text.as_str()).collect::<String>())
.collect();
assert!(
rendered.contains(&"\u{2022} first".to_string()),
"rendered lines: {rendered:?}"
);
assert!(
rendered.contains(&"\u{2022} second".to_string()),
"rendered lines: {rendered:?}"
);
}
#[test]
fn a_heading_is_rendered_bold() {
let lines = markdown_to_runs("# Title");
let bold_run = lines.iter().flatten().find(|r| r.text.contains("Title"));
assert!(
bold_run.is_some_and(|r| r.bold),
"expected the heading text to be a bold run"
);
}
#[test]
fn wrap_runs_keeps_every_line_within_max_width() {
let mut canvas = Canvas::new(400, 400).unwrap();
let lines =
markdown_to_runs("the quick brown **fox** jumps over the lazy dog again and again");
let wrapped = wrap_runs(&mut canvas, &lines, 100.0);
assert!(
wrapped.len() > 1,
"expected wrapping to produce multiple lines"
);
for line in &wrapped {
let mut width = 0.0f32;
for run in line {
canvas.font(run_font(run));
width += canvas.text_width(&run.text);
}
assert!(
width <= 101.0,
"line {line:?} exceeds the 100px max width ({width})"
);
}
}
}