use std::{ path::{Path, PathBuf}, sync::Arc, }; use base64::Engine as _; use gpui::{ AnyElement, App, Asset, ElementId, Hsla, Image, ImageAssetLoader, ImageFormat, InteractiveElement, IntoElement, ParentElement, RenderImage, RenderOnce, Resource, SMOOTH_SVG_SCALE_FACTOR, SharedString, Styled, Window, div, img, px, }; use crate::{ i18n, primitives::{Icon, IconName, hold}, theme::{ActiveTheme, IconSize, Radius}, }; /// Where a picture's bytes come from. #[derive(Clone, Debug, PartialEq)] pub(crate) enum Origin { File(PathBuf), /// Bytes inline in the address, and their type. Data(ImageFormat, Arc<[u8]>), Web, /// A relative path with no folder to read it in. Loose, /// An address that names no picture, and why. Bad(SharedString), } /// `%XX` decoded; a broken escape stays as written. fn decoded(text: &str) -> Vec { let bytes = text.as_bytes(); let (mut out, mut at) = (Vec::with_capacity(bytes.len()), 0); while at < bytes.len() { let pair = bytes.get(at + 1..at + 3); let hex = pair.and_then(|pair| u8::from_str_radix(std::str::from_utf8(pair).ok()?, 16).ok()); match (bytes[at], hex) { (b'%', Some(byte)) => (out.push(byte), at += 3), (byte, _) => (out.push(byte), at += 1), }; } out } /// A path written in a link, `%XX` decoded. fn path(written: &str) -> Result { String::from_utf8(decoded(written)) .map(PathBuf::from) .map_err(|_| "the path is not UTF-8".into()) } /// Where `url` points, read from the folder `base`. pub(crate) fn origin(url: &str, base: Option<&Path>) -> Origin { let url = url.trim(); let lower = url.to_ascii_lowercase(); if lower.starts_with("http://") || lower.starts_with("https://") || url.starts_with("//") { return Origin::Web; } if lower.starts_with("data:") { return inline(url); } if lower.starts_with("file:") { return file(&url[5..]); } let scheme = url.split_once(':').is_some_and(|(scheme, _)| { scheme.len() > 1 && scheme .chars() .all(|ch| ch.is_ascii_alphanumeric() || "+-.".contains(ch)) }); if scheme { return Origin::Bad(format!("{url} is no picture address").into()); } let written = url.split(['?', '#']).next().unwrap_or_default(); match path(written) { Ok(path) if path.is_absolute() => Origin::File(path), Ok(path) => base.map_or(Origin::Loose, |base| Origin::File(base.join(path))), Err(why) => Origin::Bad(why), } } /// A `file:` address after its scheme: host, path, no query. fn file(rest: &str) -> Origin { let rest = rest.split(['?', '#']).next().unwrap_or_default(); let (host, written) = match rest.strip_prefix("//") { Some(after) => after.split_at(after.find('/').unwrap_or(after.len())), None => ("", rest), }; let local = host.is_empty() || host.eq_ignore_ascii_case("localhost"); let written = match (local, written.as_bytes()) { // Windows writes file:///C:/… (true, [b'/', _, b':', ..]) if cfg!(windows) => written[1..].to_string(), (true, _) => written.to_string(), (false, _) if cfg!(windows) => format!("//{host}{written}"), (false, _) => return Origin::Bad(format!("files on {host} are not read").into()), }; path(&written).map_or_else(Origin::Bad, Origin::File) } /// A `data:` address: base64 bytes of an image type. fn inline(url: &str) -> Origin { let Some((head, payload)) = url[5..].split_once(',') else { return Origin::Bad("the data address has no comma".into()); }; let Some(mime) = head.strip_suffix(";base64") else { return Origin::Bad("only base64 data addresses draw".into()); }; let Some(format) = ImageFormat::from_mime_type(&mime.to_ascii_lowercase()) else { return Origin::Bad(format!("{mime} is no image type").into()); }; match base64::engine::general_purpose::STANDARD.decode(payload.trim()) { Ok(bytes) => Origin::Data(format, bytes.into()), Err(error) => Origin::Bad(format!("{error}").into()), } } /// Inline bytes decoded as gpui decodes files. enum Unpacker {} impl Asset for Unpacker { type Source = (ImageFormat, Arc<[u8]>); type Output = Result, SharedString>; fn load( (format, bytes): Self::Source, cx: &mut App, ) -> impl Future + Send + 'static { let renderer = cx.svg_renderer(); async move { Image::from_bytes(format, bytes.to_vec()) .to_image_data(renderer) .map_err(|error| format!("{error:#}").into()) } } } /// The picture at `url`, natural size up to the column. #[derive(IntoElement)] pub(crate) struct Picture { pub id: ElementId, pub alt: String, pub url: String, /// The folder relative addresses start from. pub base: Option>, } impl RenderOnce for Picture { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { picture(self, window, cx) } } fn picture(shown: Picture, window: &mut Window, cx: &mut App) -> AnyElement { let Picture { id, alt, url, base } = shown; let (alt, url) = (alt.as_str(), url.as_str()); let colors = cx.theme().colors.clone(); let (loaded, svg) = match origin(url, base.as_deref()) { Origin::File(path) => { let svg = path .extension() .is_some_and(|ext| ext.eq_ignore_ascii_case("svg")); let resource = Resource::Path(path.into()); let image = |loaded: &Result, _>| loaded.as_ref().ok().cloned(); let loaded = hold::(id, &resource, image, window, cx) .map(|loaded| loaded.map_err(|error| SharedString::from(error.to_string()))); (loaded, svg) } Origin::Data(format, bytes) => { let svg = format == ImageFormat::Svg; let image = |loaded: &Result, _>| loaded.as_ref().ok().cloned(); ( hold::(id, &(format, bytes), image, window, cx), svg, ) } Origin::Web => { let said = i18n::text(cx, "markdown.picture.web", &[]); return note(alt, said, colors.fg_subtle, cx); } Origin::Loose => { let said = i18n::text(cx, "markdown.picture.folder", &[]); return note(alt, said, colors.fg_subtle, cx); } Origin::Bad(why) => return note(alt, why, colors.danger, cx), }; match loaded { Some(Ok(image)) => { let natural = image.size(0); // The picture's own height follows its ratio. let smooth = if svg { SMOOTH_SVG_SCALE_FACTOR } else { 1.0 }; let width = i32::from(natural.width) as f32 / smooth; div() .debug_selector(|| "markdown-picture".into()) .w_full() .max_w(px(width)) .child(img(image).size_full()) .into_any_element() } Some(Err(why)) => { log::warn!("markdown: a picture did not load"); note(alt, why, colors.danger, cx) } None => note(alt, url.to_string().into(), colors.fg_subtle, cx), } } /// A picture not drawn: its alt text and why. fn note(alt: &str, said: SharedString, tone: Hsla, cx: &App) -> AnyElement { let theme = cx.theme(); let colors = &theme.colors; div() .flex() .items_center() .gap_2() .p_3() .rounded(theme.radius(Radius::Md)) .border_1() .border_color(colors.border) .text_color(colors.fg_muted) .child( Icon::new(IconName::Image) .size(IconSize::Sm) .color(colors.fg_subtle), ) .child(div().flex_1().child(alt.to_string())) .child(div().text_color(tone).child(said)) .into_any_element() } #[cfg(test)] mod tests { use super::*; #[test] fn addresses_resolve_against_the_folder() { let base = Path::new("/notes"); assert_eq!( origin("assets/a%20b.png?x#y", Some(base)), Origin::File(base.join("assets/a b.png")) ); assert_eq!(origin("assets/a.png", None), Origin::Loose); assert_eq!(origin("https://e.x/a.png", Some(base)), Origin::Web); assert_eq!(origin("//e.x/a.png", Some(base)), Origin::Web); assert!(matches!(origin("mailto:a@b", Some(base)), Origin::Bad(_))); let absolute = std::env::current_dir().expect("a dir").join("a.png"); assert_eq!( origin(&absolute.display().to_string(), Some(base)), Origin::File(absolute) ); } #[test] fn file_addresses_decode_without_query_or_fragment() { for url in [ "file:///tmp/a%23b.png", "file://localhost/tmp/a%23b.png#x", "FILE:/tmp/a%23b.png?y", ] { assert_eq!( origin(url, None), Origin::File(PathBuf::from("/tmp/a#b.png")), "{url}" ); } if cfg!(windows) { assert_eq!( origin("file:///C:/notes/a.png#preview", None), Origin::File(PathBuf::from("C:/notes/a.png")) ); } } #[test] fn a_file_on_another_host_is_its_share_or_refused() { let found = origin("file://server/share/a.png", Some(Path::new("/notes"))); match cfg!(windows) { true => assert_eq!(found, Origin::File(PathBuf::from("//server/share/a.png"))), false => assert!(matches!(found, Origin::Bad(_)), "{found:?}"), } } #[test] fn data_addresses_carry_their_bytes() { assert_eq!( origin("data:image/png;base64,cG5n", None), Origin::Data(ImageFormat::Png, Arc::from(&b"png"[..])) ); assert!(matches!(origin("data:image/png,raw", None), Origin::Bad(_))); assert!(matches!( origin("data:text/plain;base64,eA==", None), Origin::Bad(_) )); assert!(matches!( origin("data:image/png;base64,***", None), Origin::Bad(_) )); } }