use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd};
use ratatui::layout::Alignment as RatatuiAlignment;
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use unicode_width::UnicodeWidthStr;
use crate::layout;
use crate::style::{Alignment, MarkdownStyle};
#[derive(Debug, Clone)]
pub struct ImageInfo {
pub url: String,
pub alt: String,
pub line_index: usize,
}
#[derive(Debug, Clone)]
pub struct LinkInfo {
pub url: String,
pub text: String,
pub line_index: usize,
}
#[derive(Debug, Clone)]
pub struct RenderResult {
pub lines: Vec<Line<'static>>,
pub images: Vec<ImageInfo>,
pub links: Vec<LinkInfo>,
}
pub fn render_markdown(input: &str, style: &MarkdownStyle, width: u16) -> RenderResult {
let parser = Parser::new_ext(input, Options::all());
let mut r = Renderer {
style,
width,
output: Vec::new(),
images: Vec::new(),
links: Vec::new(),
spans: Vec::new(),
mods: Vec::new(),
block_style: style.paragraph,
block_alignment: Alignment::Left,
pen_img_line: None,
link_url: None,
link_text: String::new(),
is_image: false,
pending_img_alt: String::new(),
in_blockquote: 0,
code_buf: None,
list_counters: Vec::new(),
item_prefix: None,
has_content: false,
};
for event in parser {
r.handle(event);
}
r.flush_paragraph(style.paragraph);
RenderResult {
lines: r.output,
images: r.images,
links: r.links,
}
}
struct Renderer<'a> {
style: &'a MarkdownStyle,
width: u16,
output: Vec<Line<'static>>,
images: Vec<ImageInfo>,
links: Vec<LinkInfo>,
spans: Vec<Span<'static>>,
mods: Vec<Style>,
block_style: Style,
block_alignment: Alignment,
pen_img_line: Option<usize>,
link_url: Option<String>,
link_text: String,
is_image: bool,
pending_img_alt: String,
in_blockquote: u32,
code_buf: Option<String>,
list_counters: Vec<ListCounter>,
item_prefix: Option<String>,
has_content: bool,
}
#[derive(Clone, Copy)]
enum ListCounter {
Bullet,
Ordered(u64),
}
struct StyledWord {
text: String,
style: Style,
}
impl<'a> Renderer<'a> {
fn handle(&mut self, event: Event<'a>) {
match event {
Event::Start(tag) => self.start(tag),
Event::End(tag_end) => self.end(tag_end),
Event::Text(text) => self.on_text(&text),
Event::Code(text) => {
let s = text.to_string();
let mut style = self.style.inline_code;
for patch in &self.mods {
style = style.patch(*patch);
}
self.spans.push(Span::styled(s.clone(), style));
if self.link_url.is_some() {
self.link_text.push_str(&s);
}
}
Event::Html(text) | Event::InlineHtml(text) => {
self.spans.push(Span::raw(text.into_string()));
}
Event::SoftBreak => {
self.spans.push(Span::raw(" "));
if self.link_url.is_some() {
self.link_text.push(' ');
}
}
Event::HardBreak => {
self.flush_paragraph(self.style.paragraph);
}
Event::Rule => {
self.flush_paragraph(self.style.paragraph);
let mut line = String::new();
let w = self.width.max(1) as usize;
for _ in 0..w {
line.push(self.style.hr_char);
}
let mut l = Line::from(Span::styled(line, self.style.hr_style));
l.alignment = Self::map_alignment(self.block_alignment);
self.output.push(l);
}
Event::TaskListMarker(checked) => {
let mark = if checked { "☑ " } else { "☐ " };
self.spans.push(Span::raw(mark));
}
_ => {}
}
}
fn start(&mut self, tag: Tag<'a>) {
match tag {
Tag::Paragraph => {
self.block_style = if self.in_blockquote > 0 {
self.style.quote
} else {
self.style.paragraph
};
self.block_alignment = if self.in_blockquote > 0 {
self.style.quote_alignment
} else {
self.style.paragraph_alignment
};
self.mods.clear();
}
Tag::Heading { level, .. } => {
let (sty, aln) = match level {
HeadingLevel::H1 => (self.style.heading_1, self.style.heading_1_alignment),
HeadingLevel::H2 => (self.style.heading_2, self.style.heading_2_alignment),
HeadingLevel::H3 => (self.style.heading_3, self.style.heading_3_alignment),
_ => (self.style.heading_3, self.style.heading_3_alignment),
};
self.block_style = sty;
self.block_alignment = aln;
self.mods.clear();
}
Tag::BlockQuote(_) => {
self.flush_paragraph(self.style.paragraph);
self.in_blockquote += 1;
self.block_alignment = self.style.quote_alignment;
}
Tag::CodeBlock(_) => {
self.flush_paragraph(self.style.paragraph);
self.block_alignment = self.style.code_block_alignment;
self.code_buf = Some(String::new());
}
Tag::List(start) => {
self.flush_paragraph(self.style.paragraph);
let counter = match start {
Some(n) => ListCounter::Ordered(n),
None => ListCounter::Bullet,
};
self.list_counters.push(counter);
}
Tag::Item => {
self.flush_paragraph(self.style.paragraph);
self.has_content = false;
if let Some(counter) = self.list_counters.last_mut() {
match counter {
ListCounter::Bullet => {
self.item_prefix = Some(self.style.list_bullet.to_string());
}
ListCounter::Ordered(n) => {
let label = self.style.ordered_template.replace("{}", &n.to_string());
self.item_prefix = Some(label);
*n += 1;
}
}
}
}
Tag::Emphasis => self.mods.push(self.style.italic),
Tag::Strong => self.mods.push(self.style.bold),
Tag::Strikethrough => self.mods.push(self.style.strikethrough),
Tag::Link { dest_url, .. } => {
self.link_url = Some(dest_url.into_string());
self.link_text.clear();
if !self.style.link_prefix.is_empty() {
self.spans.push(Span::styled(
self.style.link_prefix.to_string(),
self.current_style(),
));
}
}
Tag::Image { dest_url, .. } => {
self.is_image = true;
self.link_url = Some(dest_url.into_string());
self.pending_img_alt.clear();
}
_ => {}
}
}
fn end(&mut self, tag: TagEnd) {
match tag {
TagEnd::Paragraph => {
self.flush_paragraph(self.style.paragraph);
self.has_content = true;
}
TagEnd::Heading(level) => {
let style = match level {
HeadingLevel::H1 => self.style.heading_1,
HeadingLevel::H2 => self.style.heading_2,
_ => self.style.heading_3,
};
self.flush_paragraph(style);
}
TagEnd::BlockQuote(_) => {
self.flush_paragraph(self.style.paragraph);
self.in_blockquote = self.in_blockquote.saturating_sub(1);
}
TagEnd::CodeBlock => {
if let Some(buf) = self.code_buf.take() {
let mut lines = layout::wrap_code_block(
&buf,
self.width as usize,
self.style.code_block,
self.style.code_block_bg,
);
self.apply_alignment(&mut lines);
self.output.extend(lines);
}
}
TagEnd::List(_) => {
self.flush_paragraph(self.style.paragraph);
self.list_counters.pop();
self.item_prefix = None;
}
TagEnd::Item => {
if !self.has_content {
self.flush_paragraph(self.style.paragraph);
}
self.item_prefix = None;
}
TagEnd::Emphasis => {
self.mods.pop();
}
TagEnd::Strong => {
self.mods.pop();
}
TagEnd::Strikethrough => {
self.mods.pop();
}
TagEnd::Link => {
self.finalize_link();
}
TagEnd::Image => {
self.finalize_image();
}
_ => {}
}
}
fn on_text(&mut self, text: &str) {
if self.is_image {
self.pending_img_alt.push_str(text);
return;
}
let text = if let Some(img_line) = self.pen_img_line.take() {
let punct_count = text
.chars()
.take_while(|c| c.is_ascii_punctuation())
.count();
if punct_count > 0 {
if let Some(line) = self.output.get_mut(img_line) {
line.spans.push(Span::raw(text[..punct_count].to_string()));
}
&text[punct_count..]
} else {
text
}
} else {
text
};
if let Some(buf) = &mut self.code_buf {
buf.push_str(text);
return;
}
let style = self.current_style();
self.spans.push(Span::styled(text.to_string(), style));
if self.link_url.is_some() {
self.link_text.push_str(text);
}
}
fn current_style(&self) -> Style {
let mut s = self.block_style;
for patch in &self.mods {
s = s.patch(*patch);
}
if self.link_url.is_some() {
s = s.patch(self.style.link);
}
s
}
fn finalize_link(&mut self) {
if let Some(url) = self.link_url.take() {
let text = std::mem::take(&mut self.link_text);
self.links.push(LinkInfo {
line_index: usize::MAX,
url,
text,
});
}
}
fn finalize_image(&mut self) {
let url = self.link_url.take().unwrap_or_default();
let alt = std::mem::take(&mut self.pending_img_alt);
let display = if alt.is_empty() {
format!("{}image", self.style.image_prefix)
} else {
format!("{}{}", self.style.image_prefix, alt)
};
self.is_image = false;
let had_content = !self.spans.is_empty() || self.item_prefix.is_some();
if had_content {
self.flush_paragraph(self.current_style());
}
let mut line = Line::from(vec![Span::styled(display, self.style.image)]);
line.alignment = Self::map_alignment(self.block_alignment);
self.output.push(line);
let current = self.output.len() - 1;
if had_content {
self.pen_img_line = Some(current);
}
self.images.push(ImageInfo {
line_index: current,
url,
alt,
});
}
fn flush_paragraph(&mut self, base_style: Style) {
let spans = std::mem::take(&mut self.spans);
if spans.is_empty() && self.item_prefix.is_none() {
return;
}
match self.block_alignment {
Alignment::Justify => self.flush_justified(spans, base_style),
_ => self.flush_simple(spans, base_style),
}
}
fn flush_simple(&mut self, spans: Vec<Span<'static>>, base_style: Style) {
let prefix = self.build_first_prefix();
let mut line_spans: Vec<Span<'static>> = Vec::new();
if !prefix.is_empty() {
line_spans.push(Span::styled(prefix, base_style));
}
line_spans.extend(spans);
let mut line = Line::from(line_spans);
line.alignment = match self.block_alignment {
Alignment::Center => Some(RatatuiAlignment::Center),
Alignment::Right => Some(RatatuiAlignment::Right),
_ => None,
};
self.push_line(line);
}
fn flush_justified(&mut self, spans: Vec<Span<'static>>, base_style: Style) {
let first_prefix = self.build_first_prefix();
let cont_prefix = self.build_cont_prefix();
let first_pw = UnicodeWidthStr::width(first_prefix.as_str());
let cont_pw = UnicodeWidthStr::width(cont_prefix.as_str());
let words = Self::spans_to_words(&spans);
if words.is_empty() {
let mut line = Line::from(Span::styled(first_prefix, base_style));
line.alignment = None;
self.push_line(line);
return;
}
let first_avail = (self.width as usize)
.saturating_sub(first_pw)
.saturating_sub(1)
.max(1);
let cont_avail = (self.width as usize)
.saturating_sub(cont_pw)
.saturating_sub(1)
.max(1);
let word_lines = self.justify_word_wrap(&words, first_avail, cont_avail);
let total = word_lines.len();
for (i, wl) in word_lines.iter().enumerate() {
let prefix = if i == 0 { &first_prefix } else { &cont_prefix };
let avail = if i == 0 { first_avail } else { cont_avail };
let is_last = i == total - 1;
let mut line_spans: Vec<Span<'static>> = Vec::new();
if !prefix.is_empty() {
line_spans.push(Span::styled(prefix.to_string(), base_style));
}
if is_last {
for (j, word) in wl.iter().enumerate() {
if j > 0 {
line_spans.push(Span::raw(" "));
}
line_spans.push(Span::styled(word.text.clone(), word.style));
}
} else {
let gaps = wl.len().saturating_sub(1);
if gaps == 0 {
line_spans.push(Span::styled(wl[0].text.clone(), wl[0].style));
} else {
let word_widths: usize = wl
.iter()
.map(|w| UnicodeWidthStr::width(w.text.as_str()))
.sum();
let text_width = word_widths + gaps;
let deficit = avail.saturating_sub(text_width);
let extra_per = deficit / gaps;
let remainder = deficit % gaps;
for (j, word) in wl.iter().enumerate() {
line_spans.push(Span::styled(word.text.clone(), word.style));
if j < gaps {
let spaces = 1 + extra_per + if j < remainder { 1 } else { 0 };
line_spans.push(Span::raw(" ".repeat(spaces)));
}
}
}
}
self.push_line(Line::from(line_spans));
}
}
fn push_line(&mut self, line: Line<'static>) {
let current = self.output.len();
self.output.push(line);
for img in self
.images
.iter_mut()
.rev()
.take_while(|i| i.line_index == usize::MAX)
{
img.line_index = current;
}
for link in self
.links
.iter_mut()
.rev()
.take_while(|l| l.line_index == usize::MAX)
{
link.line_index = current;
}
}
fn build_first_prefix(&self) -> String {
let mut p = String::new();
for _ in 0..self.in_blockquote {
p.push_str(self.style.quote_indicator);
}
if let Some(ref item) = self.item_prefix {
p.push_str(item);
}
p
}
fn build_cont_prefix(&self) -> String {
let mut p = String::new();
for _ in 0..self.in_blockquote {
p.push_str(self.style.quote_indicator);
}
p
}
fn spans_to_words(spans: &[Span<'static>]) -> Vec<StyledWord> {
let mut words = Vec::new();
for span in spans {
for word in span.content.split_ascii_whitespace() {
if !word.is_empty() {
words.push(StyledWord {
text: word.to_string(),
style: span.style,
});
}
}
}
words
}
fn justify_word_wrap<'b>(
&self,
words: &'b [StyledWord],
first_avail: usize,
cont_avail: usize,
) -> Vec<Vec<&'b StyledWord>> {
let mut lines: Vec<Vec<&StyledWord>> = Vec::new();
let mut current: Vec<&StyledWord> = Vec::new();
let mut current_width: usize = 0;
for word in words {
let w = UnicodeWidthStr::width(word.text.as_str());
let avail = if lines.is_empty() {
first_avail
} else {
cont_avail
};
if current.is_empty() {
current.push(word);
current_width = w;
} else if current_width + 1 + w <= avail {
current.push(word);
current_width += 1 + w;
} else {
lines.push(current);
current = vec![word];
current_width = w;
}
}
if !current.is_empty() {
lines.push(current);
}
lines
}
fn map_alignment(aln: Alignment) -> Option<RatatuiAlignment> {
match aln {
Alignment::Center => Some(RatatuiAlignment::Center),
Alignment::Right => Some(RatatuiAlignment::Right),
_ => None,
}
}
fn apply_alignment(&self, lines: &mut [Line<'static>]) {
let aln = Self::map_alignment(self.block_alignment);
for line in lines.iter_mut() {
line.alignment = aln;
}
}
}
pub fn render_markdown_with_extra(
input: &str,
style: &MarkdownStyle,
width: u16,
extra_images: &[String],
) -> RenderResult {
let mut result = render_markdown(input, style, width);
for url in extra_images {
if !result.images.iter().any(|i| &i.url == url) {
result.images.push(ImageInfo {
url: url.clone(),
alt: String::new(),
line_index: 0,
});
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
fn default_style() -> MarkdownStyle {
MarkdownStyle::default()
}
#[test]
fn empty_input() {
let result = render_markdown("", &default_style(), 80);
assert!(result.lines.is_empty());
assert!(result.images.is_empty());
assert!(result.links.is_empty());
}
#[test]
fn plain_text() {
let result = render_markdown("hello world", &default_style(), 80);
assert_eq!(result.lines.len(), 1);
}
#[test]
fn heading() {
let result = render_markdown("# Title", &default_style(), 80);
assert_eq!(result.lines.len(), 1);
assert!(result.lines[0].to_string().contains("Title"));
}
#[test]
fn heading_style_differs_from_paragraph() {
let s = MarkdownStyle::default();
let para = render_markdown("paragraph text", &s, 80);
let head = render_markdown("# heading text", &s, 80);
let p = format!("{:?}", para.lines[0]);
let h = format!("{:?}", head.lines[0]);
assert_ne!(p, h, "heading style should differ from paragraph");
}
#[test]
fn code_block() {
let result = render_markdown("```\nlet x = 1;\n```", &default_style(), 80);
assert_eq!(result.lines.len(), 1);
}
#[test]
fn image_tracked() {
let result = render_markdown("", &default_style(), 80);
assert_eq!(result.lines.len(), 1);
assert_eq!(result.images.len(), 1);
assert_eq!(result.images[0].url, "img.png");
assert_eq!(result.images[0].alt, "alt");
}
#[test]
fn link_tracked() {
let result = render_markdown("[text](https://ex.com)", &default_style(), 80);
assert_eq!(result.lines.len(), 1);
assert_eq!(result.links.len(), 1);
assert_eq!(result.links[0].url, "https://ex.com");
assert_eq!(result.links[0].text, "text");
}
#[test]
fn bold_and_italic() {
let result = render_markdown("**bold** and *italic*", &default_style(), 80);
assert_eq!(result.lines.len(), 1);
}
#[test]
fn strikethrough_with_code() {
let result = render_markdown("~~`code`~~", &default_style(), 80);
assert_eq!(result.lines.len(), 1);
}
#[test]
fn list_ordered() {
let result = render_markdown("1. one\n2. two", &default_style(), 80);
assert_eq!(result.lines.len(), 2);
}
#[test]
fn list_unordered() {
let result = render_markdown("- one\n- two", &default_style(), 80);
assert_eq!(result.lines.len(), 2);
}
#[test]
fn justify_paragraph_wraps_and_fits_width() {
let style = MarkdownStyle {
paragraph_alignment: Alignment::Justify,
..default_style()
};
let result = render_markdown(
"This long paragraph is justified. Every line except the last is padded with \
extra spaces so that both the left and right edges are perfectly aligned. \
This is the classic newspaper-style typesetting. The last line stays \
left-aligned as is conventional.",
&style,
60,
);
assert!(result.lines.len() >= 3, "should wrap to multiple lines");
for (i, l) in result.lines.iter().enumerate() {
let w = UnicodeWidthStr::width(l.to_string().as_str());
assert!(w <= 60, "line {} width {} exceeds max 60", i, w);
}
}
#[test]
fn justify_with_image_no_duplicate_words() {
let style = MarkdownStyle {
paragraph_alignment: Alignment::Justify,
..default_style()
};
let text = "An image: . More text.";
let result = render_markdown(text, &style, 80);
let combined: String = result
.lines
.iter()
.map(|l| l.to_string())
.collect::<Vec<_>>()
.join(" ");
assert_eq!(
combined.matches("alt").count(),
1,
"alt text should not be duplicated"
);
}
}