ffxii-tza-auto-notes/src/util/markdown_renderer.rs

328 lines
11 KiB
Rust
Executable File

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<usize>,
in_image: Option<(String, String)>,
rendered: Vec<Box<dyn Widget<App>>>,
}
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<Box<dyn Widget<App>>> {
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<NotesState>, _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<usize>) {
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<Item=Event<'e>>) {
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<ComplexTableColumnWidth> = (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<Item=Event<'e>>) {
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);
}
}
}
}