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 3 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(),
))
}
188 changes: 81 additions & 107 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 ImaheHandleCache {
emilk marked this conversation as resolved.
Show resolved Hide resolved
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 ImaheHandleCache {
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 ImaheHandleCache {}
emilk marked this conversation as resolved.
Show resolved Hide resolved

/// 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<ImaheHandleCache>>,

#[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 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 @@ -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