Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Show image loading errors to the user #8

Merged
merged 5 commits into from
Aug 29, 2023
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 Diff line number Diff line change
Expand Up @@ -14,10 +14,14 @@ include = ["src/*.rs", "LICENSE-MIT", "LICENSE-APACHE", "Cargo.toml"]

[dependencies]
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"] }
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 }
usvg = { version = "0.35.0", optional = true }
Expand Down
29 changes: 29 additions & 0 deletions src/fetch_data.rs
Original file line number 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 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 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::{ColorImage, TextureHandle};
use parking_lot::Mutex;
use poll_promise::Promise;
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")]
use syntect::{
Expand All @@ -34,65 +39,69 @@ use syntect::{
util::LinesWithEndings,
};

fn 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()))
#[derive(Default, Debug)]
struct ScrollableCache {
available_size: Vec2,
page_size: Option<Vec2>,
split_points: Vec<(usize, Pos2, Pos2)>,
}

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

#[cfg(feature = "svg")]
fn try_render_svg(data: &[u8]) -> Option<ColorImage> {
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).ok()?;
tree.convert_text(&fontdb);
resvg::Tree::from_usvg(&tree)
};
impl ImageHandleCache {
fn clear(&mut self) {
self.cache.clear();
}

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())?;
tree.render(tiny_skia::Transform::default(), &mut pixmap.as_mut());
Err(err) => {
sender.send(Err(err));
}
};
ctx.request_repaint();
});
promise
});

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

#[derive(Default, Debug)]
struct ScrollableCache {
available_size: Vec2,
page_size: Option<Vec2>,
split_points: Vec<(usize, Pos2, Pos2)>,
fn loaded(&self) -> impl Iterator<Item = &TextureHandle> {
self.cache
.values()
.flat_map(|p| p.ready())
.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.
pub struct CommonMarkCache {
// Everything stored here must take into account that the cache is for multiple
// CommonMarkviewers with different source_ids.
images: ImageHashMap,
images: Arc<Mutex<ImageHandleCache>>,

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

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

link_hooks: HashMap<String, bool>,

scroll: HashMap<Id, ScrollableCache>,
}

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

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

/// 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
/// 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
/// [`CommonMarkViewer::show`] gets called
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 {
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];
if width >= max {
max = width;
Expand Down Expand Up @@ -441,7 +450,7 @@ struct Link {
}

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

impl CommonMarkViewerInternal {
/// Be aware that this aquires egui::Context internally.
/// Be aware that this acquires egui::Context internally.
pub fn show(
&mut self,
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) {
let handle = match cache.images.lock().unwrap().entry(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
}
};
let handle = cache.images.lock().load(ui.ctx(), url.clone());

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

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

if !image.alt_text.is_empty() && options.show_alt_text_on_hover {
response.on_hover_ui_at_pointer(|ui| {
for alt in image.alt_text {
ui.label(alt);
}
});
let url = &image.url;
match image.handle {
Poll::Ready(Ok(texture)) => {
let size = options.image_scaled(&texture);
let response = ui.image(&texture, size);

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

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, ' '))
}

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

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