use std::ops::RangeBounds; use std::path::Path; use druid::{Color, Env, FontFamily, FontStyle, FontWeight, ImageBuf, Widget, WidgetExt}; use druid::piet::ImageFormat; use druid::text::RichTextBuilder; use druid::widget::{FillStrat, Flex}; use druid_widget_nursery::table::{ComplexTableColumnWidth, FlexTable, TableCellVerticalAlignment, TableColumnWidth, TableRow}; use image::GenericImageView; use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag}; use crate::app::App; use crate::NotesState; use crate::widget::rich_text_display::RichTextDisplay; pub struct MarkdownRenderer<'e, 'p> { src: &'e str, path: &'p str, pos: (usize, usize), add_newline: bool, current_pos: usize, builder: RichTextBuilder, tag_stack: Vec<(usize, Tag<'e>)>, list_depth: usize, in_table: Option, in_image: Option<(String, String)>, rendered: Vec>>, } impl<'e, 'p> MarkdownRenderer<'e, 'p> { const BULLET: &'static str = "• "; pub fn new(src: &'e str, path: &'p str, pos: (usize, usize)) -> Self { Self { src, path, pos, add_newline: false, current_pos: 0, builder: RichTextBuilder::new(), tag_stack: Vec::new(), list_depth: 0, in_table: None, in_image: None, rendered: Vec::with_capacity(1), } } pub fn render(mut self) -> Vec>> { let mut events = Parser::new_ext(self.src, Options::ENABLE_TABLES); while let Some(event) = events.next() { self.event(event); self.image(&mut events); self.table(&mut events); } self.render_builder(); self.rendered } fn render_builder(&mut self) { let pos = self.pos.clone(); let mut builder = RichTextBuilder::new(); std::mem::swap(&mut builder, &mut self.builder); self.current_pos = 0; let built = builder.build(); if built.is_empty() { return; } self.rendered.push(RichTextDisplay::new(built) .disabled_if(move |data: &Option, _env: &Env| { let highlight_pos = data .as_ref() .and_then(|state| state.step_idx.map(|step_idx| (step_idx, state.area_idx))); highlight_pos != Some(pos) }) .lens(App::notes_state) .boxed()); } fn builder_push(&mut self, s: &str) { self.builder.push(s); self.current_pos += s.len(); } fn add_attribute_for_tag(&mut self, tag: &Tag, range: impl RangeBounds) { let mut attrs = self.builder.add_attributes_for_range(range); match tag { Tag::Heading(lvl, _, _) => { let font_size = match lvl { HeadingLevel::H1 => 38.0, HeadingLevel::H2 => 32.0, HeadingLevel::H3 => 26.0, HeadingLevel::H4 => 20.0, HeadingLevel::H5 => 16.0, HeadingLevel::H6 => 12.0, _ => 12.0, }; attrs.size(font_size).weight(FontWeight::BOLD); } Tag::BlockQuote => { attrs.style(FontStyle::Italic).text_color(Color::GRAY); } Tag::CodeBlock(_) => { attrs.font_family(FontFamily::MONOSPACE); } Tag::Emphasis => { attrs.style(FontStyle::Italic); } Tag::Strong => { attrs.weight(FontWeight::BOLD); } Tag::Strikethrough => { attrs.strikethrough(true); } Tag::Link(_link_ty, _target, _title) => { attrs .underline(true) .text_color(Color::AQUA); // .link(OPEN_LINK.with(target.to_string())); } // ignore other tags for now _ => (), } } fn add_newline_after_tag(tag: &Tag) -> bool { !matches!( tag, Tag::Emphasis | Tag::Strong | Tag::Strikethrough | Tag::Link(..), ) } fn event(&mut self, event: Event<'e>) { match event { Event::Start(tag) => { if let Tag::Table(aligns) = &tag { self.in_table = Some(aligns.len()); self.add_newline = false; } if let Tag::Image(_, url, title) = &tag { self.in_image = Some((url.to_string(), title.to_string())); } if matches!(&tag, Tag::List(..)) { if self.list_depth > 0 { self.add_newline = true; } self.list_depth += 1; } if self.add_newline { self.builder_push("\n"); self.add_newline = false; } if matches!(&tag, Tag::Item) { for _ in 0..self.list_depth { self.builder_push(" "); } self.builder_push(Self::BULLET); } self.tag_stack.push((self.current_pos, tag)); } Event::Text(text) => { self.builder_push(&text); } Event::End(end_tag) => { if matches!(&end_tag, Tag::Table(..)) { self.in_table = None; } if matches!(&end_tag, Tag::Image(..)) { self.in_image = None; } let (start_off, tag) = self.tag_stack .pop() .expect("parser does not return unbalanced tags"); assert_eq!(end_tag, tag, "mismatched tags"); self.add_attribute_for_tag(&tag, start_off..self.current_pos); if Self::add_newline_after_tag(&tag) { self.add_newline = true; } if matches!(&tag, Tag::List(..)) { self.list_depth -= 1; } } Event::Code(text) => { self.builder.push(&text).font_family(FontFamily::MONOSPACE); self.current_pos += text.len(); } Event::Html(text) => { self.builder .push(&text) .font_family(FontFamily::MONOSPACE) .text_color(Color::RED); self.current_pos += text.len(); } Event::HardBreak => { self.add_newline = true; } _ => {} } } fn table(&mut self, mut events: impl Iterator>) { let cols = match self.in_table { Some(cols) => cols, None => return, }; // handle any text yet to render self.render_builder(); let mut start = self.rendered.len(); let mut table = FlexTable::new() .default_vertical_alignment(TableCellVerticalAlignment::Top); let widths: Vec = (0..cols) .map(|i| if i == cols - 1 { TableColumnWidth::Flex(1.0).into() } else { TableColumnWidth::Intrinsic.into() }) .collect(); table.set_column_widths(&widths); let mut row = None; while self.in_table.is_some() { if let Some(event) = events.next() { match &event { Event::Start(Tag::TableRow | Tag::TableHead) => { row = Some(TableRow::new()); } Event::Start(Tag::TableCell) => { start = self.rendered.len(); } Event::End(Tag::TableRow | Tag::TableHead) => { if let Some(row) = row.take() { table.add_row(row); } row = None; } Event::End(Tag::TableCell) => { self.render_builder(); let mut widgets: Vec<_> = self.rendered.drain(start..).collect(); if let Some(row) = &mut row { if widgets.len() == 1 { row.add_child(widgets.remove(0).padding(2.0)); } else if widgets.len() > 1 { let mut flex = Flex::column(); for widget in widgets { flex.add_child(widget.padding(2.0)); } row.add_child(flex); } } } _ => {} } self.event(event); self.add_newline = false; // never add newlines in a table } else { break; } } self.rendered.push(table.boxed()); } fn image(&mut self, mut events: impl Iterator>) { let url = match &self.in_image { Some((url, _)) => url.clone(), None => return, }; self.render_builder(); let start = self.rendered.len(); while self.in_image.is_some() { if let Some(event) = events.next() { self.event(event); self.add_newline = false; // never add newlines in an image } else { break; } } self.render_builder(); let alt: Vec<_> = self.rendered.drain(start..).collect(); let image_path = Path::new(self.path) .parent() .map(|parent| parent.join(&url)) .unwrap_or_else(|| Path::new(&url).to_owned()); match image::open(image_path) { Ok(image) => { let image = image.into_rgba8(); let width = image.width() as usize; let height = image.height() as usize; let image_buf = ImageBuf::from_raw( image.into_flat_samples().samples, ImageFormat::RgbaSeparate, width, height, ); let widget = druid::widget::Image::new(image_buf) .fill_mode(FillStrat::ScaleDown) .fix_width(width as f64); self.rendered.push(widget.boxed()); } Err(err) => { eprintln!("error loading image: {:?}", err); self.rendered.extend(alt); } } } }