Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions Cargo.toml
Original file line number Original file line Diff line number Diff line change
Expand Up @@ -14,10 +14,14 @@ include = ["src/*.rs", "LICENSE-MIT", "LICENSE-APACHE", "Cargo.toml"]


[dependencies] [dependencies]
egui = { git = "https://github.com/emilk/egui", rev = "2c7c598" } egui = { git = "https://github.com/emilk/egui", rev = "2c7c598" }
pulldown-cmark = { version = "0.9.3", default-features = false }
image = { version = "0.24", default-features = false, features = ["png"] } image = { version = "0.24", default-features = false, features = ["png"] }
parking_lot = "0.12"
poll-promise = "0.3"
pulldown-cmark = { version = "0.9.3", default-features = false }


syntect = { version = "5.0.0", optional = true, default-features = false, features = ["default-fancy"] } syntect = { version = "5.0.0", optional = true, default-features = false, features = [
"default-fancy",
] }


resvg = { version = "0.35.0", optional = true } resvg = { version = "0.35.0", optional = true }
usvg = { version = "0.35.0", optional = true } usvg = { version = "0.35.0", optional = true }
Expand Down
29 changes: 29 additions & 0 deletions src/fetch_data.rs
Original file line number Original file line Diff line number Diff line change
@@ -0,0 +1,29 @@
#[cfg(not(feature = "fetch"))]
pub fn get_image_data(uri: &str, on_done: impl 'static + Send + FnOnce(Result<Vec<u8>, String>)) {
get_image_data_from_file(uri, on_done)
}

#[cfg(feature = "fetch")]
pub fn get_image_data(uri: &str, on_done: impl 'static + Send + FnOnce(Result<Vec<u8>, String>)) {
let url = url::Url::parse(uri);
if url.is_ok() {
let uri = uri.to_owned();
ehttp::fetch(ehttp::Request::get(&uri), move |result| match result {
Ok(response) => {
on_done(Ok(response.bytes));
}
Err(err) => {
on_done(Err(err));
}
});
} else {
get_image_data_from_file(uri, on_done)
}
}

fn get_image_data_from_file(
path: &str,
on_done: impl 'static + Send + FnOnce(Result<Vec<u8>, String>),
) {
on_done(std::fs::read(path).map_err(|err| err.to_string()));
}
51 changes: 51 additions & 0 deletions src/image_loading.rs
Original file line number Original file line Diff line number Diff line change
@@ -0,0 +1,51 @@
use egui::ColorImage;

pub fn load_image(url: &str, data: &[u8]) -> Result<ColorImage, String> {
if url.ends_with(".svg") {
try_render_svg(data)
} else {
try_load_image(data).map_err(|err| err.to_string())
}
}

fn try_load_image(data: &[u8]) -> image::ImageResult<ColorImage> {
let image = image::load_from_memory(data)?;
let image_buffer = image.to_rgba8();
let size = [image.width() as usize, image.height() as usize];
let pixels = image_buffer.as_flat_samples();

Ok(ColorImage::from_rgba_unmultiplied(size, pixels.as_slice()))
}

#[cfg(not(feature = "svg"))]
fn try_render_svg(_data: &[u8]) -> Result<ColorImage, String> {
Err("SVG support not enabled".to_owned())
}

#[cfg(feature = "svg")]
fn try_render_svg(data: &[u8]) -> Result<ColorImage, String> {
use resvg::tiny_skia;
use usvg::{TreeParsing, TreeTextToPath};

let tree = {
let options = usvg::Options::default();
let mut fontdb = usvg::fontdb::Database::new();
fontdb.load_system_fonts();

let mut tree = usvg::Tree::from_data(data, &options).map_err(|err| err.to_string())?;
tree.convert_text(&fontdb);
resvg::Tree::from_usvg(&tree)
};

let size = tree.size.to_int_size();

let (w, h) = (size.width(), size.height());
let mut pixmap = tiny_skia::Pixmap::new(w, h)
.ok_or_else(|| format!("Failed to create {w}x{h} SVG image"))?;
tree.render(tiny_skia::Transform::default(), &mut pixmap.as_mut());

Ok(ColorImage::from_rgba_unmultiplied(
[pixmap.width() as usize, pixmap.height() as usize],
&pixmap.take(),
))
}
192 changes: 83 additions & 109 deletions src/lib.rs
Original file line number Original file line Diff line number Diff line change
Expand Up @@ -19,12 +19,17 @@
//! //!
//! ``` //! ```


mod fetch_data;
mod image_loading;

use std::sync::Arc;
use std::{collections::HashMap, task::Poll};

use egui::TextureHandle;
use egui::{self, epaint, Id, NumExt, Pos2, RichText, Sense, TextStyle, Ui, Vec2}; use egui::{self, epaint, Id, NumExt, Pos2, RichText, Sense, TextStyle, Ui, Vec2};
use egui::{ColorImage, TextureHandle}; use parking_lot::Mutex;
use poll_promise::Promise;
use pulldown_cmark::{CowStr, HeadingLevel, Options}; use pulldown_cmark::{CowStr, HeadingLevel, Options};
use std::collections::hash_map::Entry;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};


#[cfg(feature = "syntax_highlighting")] #[cfg(feature = "syntax_highlighting")]
use syntect::{ use syntect::{
Expand All @@ -34,65 +39,69 @@ use syntect::{
util::LinesWithEndings, util::LinesWithEndings,
}; };


fn load_image(data: &[u8]) -> image::ImageResult<ColorImage> { #[derive(Default, Debug)]
let image = image::load_from_memory(data)?; struct ScrollableCache {
let image_buffer = image.to_rgba8(); available_size: Vec2,
let size = [image.width() as usize, image.height() as usize]; page_size: Option<Vec2>,
let pixels = image_buffer.as_flat_samples(); split_points: Vec<(usize, Pos2, Pos2)>,

Ok(ColorImage::from_rgba_unmultiplied(size, pixels.as_slice()))
} }


#[cfg(not(feature = "svg"))] #[derive(Default)]
fn try_render_svg(_data: &[u8]) -> Option<ColorImage> { struct ImageHandleCache {
None cache: HashMap<String, Promise<Result<TextureHandle, String>>>,
} }


#[cfg(feature = "svg")] impl ImageHandleCache {
fn try_render_svg(data: &[u8]) -> Option<ColorImage> { fn clear(&mut self) {
use resvg::tiny_skia; self.cache.clear();
use usvg::{TreeParsing, TreeTextToPath}; }

let tree = {
let options = usvg::Options::default();
let mut fontdb = usvg::fontdb::Database::new();
fontdb.load_system_fonts();

let mut tree = usvg::Tree::from_data(data, &options).ok()?;
tree.convert_text(&fontdb);
resvg::Tree::from_usvg(&tree)
};


let size = tree.size.to_int_size(); fn load(&mut self, ctx: &egui::Context, url: String) -> Poll<Result<TextureHandle, String>> {
let promise = self.cache.entry(url.clone()).or_insert_with(|| {
let ctx = ctx.clone();
let (sender, promise) = Promise::new();
fetch_data::get_image_data(&url.clone(), move |result| {
match result {
Ok(bytes) => {
sender.send(parse_image(&ctx, &url, &bytes));
}


let mut pixmap = tiny_skia::Pixmap::new(size.width(), size.height())?; Err(err) => {
tree.render(tiny_skia::Transform::default(), &mut pixmap.as_mut()); sender.send(Err(err));
}
};
ctx.request_repaint();
});
promise
});


Some(ColorImage::from_rgba_unmultiplied( promise.poll().map(|r| r.clone())
[pixmap.width() as usize, pixmap.height() as usize], }
&pixmap.take(),
))
}


#[derive(Default, Debug)] fn loaded(&self) -> impl Iterator<Item = &TextureHandle> {
struct ScrollableCache { self.cache
available_size: Vec2, .values()
page_size: Option<Vec2>, .flat_map(|p| p.ready())
split_points: Vec<(usize, Pos2, Pos2)>, .flat_map(|r| r.as_ref().ok())
}
} }


type ImageHashMap = Arc<Mutex<HashMap<String, Option<TextureHandle>>>>; impl ImageHandleCache {}


/// A cache used for storing content such as images. /// A cache used for storing content such as images.
pub struct CommonMarkCache { pub struct CommonMarkCache {
// Everything stored here must take into account that the cache is for multiple // Everything stored here must take into account that the cache is for multiple
// CommonMarkviewers with different source_ids. // CommonMarkviewers with different source_ids.
images: ImageHashMap, images: Arc<Mutex<ImageHandleCache>>,

#[cfg(feature = "syntax_highlighting")] #[cfg(feature = "syntax_highlighting")]
ps: SyntaxSet, ps: SyntaxSet,

#[cfg(feature = "syntax_highlighting")] #[cfg(feature = "syntax_highlighting")]
ts: ThemeSet, ts: ThemeSet,

link_hooks: HashMap<String, bool>, link_hooks: HashMap<String, bool>,

scroll: HashMap<Id, ScrollableCache>, scroll: HashMap<Id, ScrollableCache>,
} }


Expand Down Expand Up @@ -179,7 +188,7 @@ impl CommonMarkCache {


/// Refetch all images /// Refetch all images
pub fn reload_images(&mut self) { pub fn reload_images(&mut self) {
self.images.lock().unwrap().clear(); self.images.lock().clear();
} }


/// Clear the cache for all scrollable elements /// Clear the cache for all scrollable elements
Expand All @@ -194,7 +203,7 @@ impl CommonMarkCache {
} }


/// If the user clicks on a link in the markdown render that has `name` as a link. The hook /// If the user clicks on a link in the markdown render that has `name` as a link. The hook
/// specified with this method will be set to true. It's status can be aquired /// specified with this method will be set to true. It's status can be acquired
/// with [`get_link_hook`](Self::get_link_hook). Be aware that all hooks are reset once /// with [`get_link_hook`](Self::get_link_hook). Be aware that all hooks are reset once
/// [`CommonMarkViewer::show`] gets called /// [`CommonMarkViewer::show`] gets called
pub fn add_link_hook<S: Into<String>>(&mut self, name: S) { pub fn add_link_hook<S: Into<String>>(&mut self, name: S) {
Expand Down Expand Up @@ -245,7 +254,7 @@ impl CommonMarkCache {


fn max_image_width(&self, options: &CommonMarkOptions) -> f32 { fn max_image_width(&self, options: &CommonMarkOptions) -> f32 {
let mut max = 0.0; let mut max = 0.0;
for i in self.images.lock().unwrap().values().flatten() { for i in self.images.lock().loaded() {
let width = options.image_scaled(i)[0]; let width = options.image_scaled(i)[0];
if width >= max { if width >= max {
max = width; max = width;
Expand Down Expand Up @@ -441,7 +450,7 @@ struct Link {
} }


struct Image { struct Image {
handle: Option<TextureHandle>, handle: Poll<Result<TextureHandle, String>>,
url: String, url: String,
alt_text: Vec<RichText>, alt_text: Vec<RichText>,
} }
Expand Down Expand Up @@ -482,7 +491,7 @@ impl CommonMarkViewerInternal {
} }


impl CommonMarkViewerInternal { impl CommonMarkViewerInternal {
/// Be aware that this aquires egui::Context internally. /// Be aware that this acquires egui::Context internally.
pub fn show( pub fn show(
&mut self, &mut self,
ui: &mut egui::Ui, ui: &mut egui::Ui,
Expand Down Expand Up @@ -985,17 +994,7 @@ impl CommonMarkViewerInternal {
} }


fn start_image(&mut self, url: String, ui: &mut Ui, cache: &mut CommonMarkCache) { fn start_image(&mut self, url: String, ui: &mut Ui, cache: &mut CommonMarkCache) {
let handle = match cache.images.lock().unwrap().entry(url.clone()) { let handle = cache.images.lock().load(ui.ctx(), url.clone());
Entry::Occupied(o) => o.get().clone(),
Entry::Vacant(v) => {
let ctx = ui.ctx();
let handle = get_image_data(&url, ctx, Arc::clone(&cache.images))
.and_then(|data| parse_image(ctx, &url, &data));

v.insert(handle.clone());
handle
}
};


self.image = Some(Image { self.image = Some(Image {
handle, handle,
Expand All @@ -1006,23 +1005,30 @@ impl CommonMarkViewerInternal {


fn end_image(&mut self, ui: &mut Ui, options: &CommonMarkOptions) { fn end_image(&mut self, ui: &mut Ui, options: &CommonMarkOptions) {
if let Some(image) = self.image.take() { if let Some(image) = self.image.take() {
if let Some(texture) = image.handle { let url = &image.url;
let size = options.image_scaled(&texture); match image.handle {
let response = ui.image(&texture, size); Poll::Ready(Ok(texture)) => {

let size = options.image_scaled(&texture);
if !image.alt_text.is_empty() && options.show_alt_text_on_hover { let response = ui.image(&texture, size);
response.on_hover_ui_at_pointer(|ui| {
for alt in image.alt_text { if !image.alt_text.is_empty() && options.show_alt_text_on_hover {
ui.label(alt); response.on_hover_ui_at_pointer(|ui| {
} for alt in image.alt_text {
}); ui.label(alt);
}
});
}
} }
} else { Poll::Ready(Err(err)) => {
ui.label("!["); ui.colored_label(
for alt in image.alt_text { ui.visuals().error_fg_color,
ui.label(alt); format!("Error loading {url}: {err}"),
);
}
Poll::Pending => {
ui.spinner();
ui.label(format!("Loading {url}…"));
} }
ui.label(format!("]({})", image.url));
} }


if self.should_insert_newline { if self.should_insert_newline {
Expand Down Expand Up @@ -1237,41 +1243,9 @@ fn width_body_space(ui: &Ui) -> f32 {
ui.fonts(|f| f.glyph_width(&id, ' ')) ui.fonts(|f| f.glyph_width(&id, ' '))
} }


fn parse_image(ctx: &egui::Context, url: &str, data: &[u8]) -> Option<TextureHandle> { fn parse_image(ctx: &egui::Context, url: &str, data: &[u8]) -> Result<TextureHandle, String> {
let image = load_image(data).ok().or_else(|| try_render_svg(data)); let image = image_loading::load_image(url, data)?;
image.map(|image| ctx.load_texture(url, image, egui::TextureOptions::LINEAR)) Ok(ctx.load_texture(url, image, egui::TextureOptions::LINEAR))
}

#[cfg(feature = "fetch")]
fn get_image_data(path: &str, ctx: &egui::Context, images: ImageHashMap) -> Option<Vec<u8>> {
let url = url::Url::parse(path);
if url.is_ok() {
let ctx2 = ctx.clone();
let path = path.to_owned();
ehttp::fetch(ehttp::Request::get(&path), move |r| {
if let Ok(r) = r {
let data = r.bytes;
if let Some(handle) = parse_image(&ctx2, &path, &data) {
// we only update if the image was loaded properly
*images.lock().unwrap().get_mut(&path).unwrap() = Some(handle);
ctx2.request_repaint();
}
}
});

None
} else {
get_image_data_from_file(path)
}
}

#[cfg(not(feature = "fetch"))]
fn get_image_data(path: &str, _ctx: &egui::Context, _images: ImageHashMap) -> Option<Vec<u8>> {
get_image_data_from_file(path)
}

fn get_image_data_from_file(url: &str) -> Option<Vec<u8>> {
std::fs::read(url).ok()
} }


#[cfg(feature = "syntax_highlighting")] #[cfg(feature = "syntax_highlighting")]
Expand Down