Skip to main content

iced_markdown_editor/
images.rs

1//! Images: paste, drop and insert; embed in the Markdown or hand them to
2//! the application; display.
3//!
4//! The editor never decides where image files live. Depending on
5//! [`ImageSettings::mode`]:
6//!
7//! | Mode | What happens with a pasted, dropped or inserted image |
8//! |---|---|
9//! | [`ImageMode::Disabled`] (default) | Nothing; [`Event::ImageRejected`](crate::Event::ImageRejected) |
10//! | [`ImageMode::Embed`] | Written into the Markdown as a `data:` URL |
11//! | [`ImageMode::Delegate`] | A placeholder is inserted and [`Event::ImagePasted`](crate::Event::ImagePasted) hands the image to the application, which answers with [`Command::ResolveImage`](crate::Command::ResolveImage) or [`Command::CancelImage`](crate::Command::CancelImage) |
12//!
13//! On the web (`wasm32`) there is no file system and no desktop clipboard:
14//! images are shown from `data:` URLs and the loader.
15
16use crate::doc::{Block, Document, Link, OBJ, Style};
17use crate::markdown;
18use std::collections::HashMap;
19use std::fmt;
20use std::path::Path;
21use std::sync::Arc;
22
23/// URL scheme of placeholders for images the application has not stored yet.
24pub(crate) const PENDING_PREFIX: &str = "pending-image:";
25/// Text shown for a placeholder.
26pub(crate) const PENDING_ALT: &str = "Saving image\u{2026}";
27
28/// What the editor does with pasted, dropped or inserted images.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
30pub enum ImageMode {
31    /// Images are not accepted.
32    #[default]
33    Disabled,
34    /// Images are written into the Markdown as `data:` URLs.
35    Embed,
36    /// Images are handed to the application, which stores them and returns
37    /// the URL to write into the Markdown.
38    Delegate,
39}
40
41/// Image handling of an editor.
42#[derive(Debug, Clone, PartialEq)]
43pub struct ImageSettings {
44    pub mode: ImageMode,
45    /// Larger images are rejected.
46    pub max_bytes: usize,
47    /// Accepted MIME types, detected from the file content.
48    pub accept: Vec<String>,
49    /// Accept image files dropped onto the window.
50    pub drop: bool,
51}
52
53impl Default for ImageSettings {
54    /// Disabled; 10 MiB; PNG, JPEG, GIF and WebP; drops accepted when enabled.
55    fn default() -> Self {
56        Self {
57            mode: ImageMode::Disabled,
58            max_bytes: 10 * 1024 * 1024,
59            accept: ["image/png", "image/jpeg", "image/gif", "image/webp"]
60                .into_iter()
61                .map(String::from)
62                .collect(),
63            drop: true,
64        }
65    }
66}
67
68impl ImageSettings {
69    pub fn disabled() -> Self {
70        Self::default()
71    }
72
73    /// Images become `data:` URLs in the Markdown.
74    pub fn embed() -> Self {
75        Self {
76            mode: ImageMode::Embed,
77            ..Self::default()
78        }
79    }
80
81    /// Images are handed to the application.
82    pub fn delegate() -> Self {
83        Self {
84            mode: ImageMode::Delegate,
85            ..Self::default()
86        }
87    }
88
89    pub fn max_bytes(mut self, max_bytes: usize) -> Self {
90        self.max_bytes = max_bytes;
91        self
92    }
93
94    pub fn accept(mut self, mime_types: impl IntoIterator<Item = impl Into<String>>) -> Self {
95        self.accept = mime_types.into_iter().map(Into::into).collect();
96        self
97    }
98
99    pub fn accept_drops(mut self, accept_drops: bool) -> Self {
100        self.drop = accept_drops;
101        self
102    }
103
104    pub(crate) fn accepts(&self, mime: &str) -> bool {
105        self.accept.is_empty() || self.accept.iter().any(|accepted| accepted == mime)
106    }
107
108    pub(crate) fn enabled(&self) -> bool {
109        self.mode != ImageMode::Disabled
110    }
111}
112
113/// Where an image came from.
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
115pub enum ImageSource {
116    /// Pasted from the clipboard.
117    Paste,
118    /// A file dropped onto the window.
119    Drop,
120    /// [`Command::InsertImage`](crate::Command::InsertImage) or
121    /// [`Command::InsertImageFile`](crate::Command::InsertImageFile).
122    Insert,
123}
124
125/// Identifies a pasted image until the application resolves or cancels it.
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
127pub struct ImageId(pub(crate) u64);
128
129impl ImageId {
130    pub fn get(self) -> u64 {
131        self.0
132    }
133}
134
135impl fmt::Display for ImageId {
136    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
137        write!(f, "{}", self.0)
138    }
139}
140
141/// An image handed to the application ([`ImageMode::Delegate`]).
142#[derive(Clone, PartialEq)]
143pub struct PastedImage {
144    pub id: ImageId,
145    /// The encoded image (clipboard pixels are encoded as PNG).
146    pub bytes: Arc<[u8]>,
147    /// Detected from the content, e.g. `image/png`.
148    pub mime: &'static str,
149    pub source: ImageSource,
150    /// Original file name for dropped and inserted files.
151    pub name: Option<String>,
152}
153
154impl PastedImage {
155    /// File extension for the image type, e.g. `png`.
156    pub fn extension(&self) -> &'static str {
157        extension_for(self.mime)
158    }
159
160    /// The original file name, or `image-<time>-<id>.<ext>` for clipboard images.
161    pub fn file_name(&self) -> String {
162        if let Some(name) = &self.name {
163            return name.clone();
164        }
165        let seconds = web_time::SystemTime::now()
166            .duration_since(web_time::UNIX_EPOCH)
167            .map(|duration| duration.as_secs())
168            .unwrap_or(0);
169        format!("image-{seconds}-{}.{}", self.id, self.extension())
170    }
171}
172
173impl fmt::Debug for PastedImage {
174    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
175        f.debug_struct("PastedImage")
176            .field("id", &self.id)
177            .field("bytes", &format_args!("{} bytes", self.bytes.len()))
178            .field("mime", &self.mime)
179            .field("source", &self.source)
180            .field("name", &self.name)
181            .finish()
182    }
183}
184
185/// Why an image was not accepted.
186#[derive(Debug, Clone, PartialEq, Eq)]
187pub enum ImageRejection {
188    /// [`ImageMode::Disabled`].
189    Disabled,
190    /// Not an accepted image type (the detected MIME type, or `unknown`).
191    UnsupportedType(String),
192    TooLarge {
193        size: usize,
194        max: usize,
195    },
196    /// The file could not be read.
197    Unreadable(String),
198}
199
200impl fmt::Display for ImageRejection {
201    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
202        match self {
203            ImageRejection::Disabled => f.write_str("images are disabled"),
204            ImageRejection::UnsupportedType(mime) => write!(f, "unsupported image type {mime}"),
205            ImageRejection::TooLarge { size, max } => {
206                write!(
207                    f,
208                    "image too large ({} KiB, maximum {} KiB)",
209                    size / 1024,
210                    max / 1024
211                )
212            }
213            ImageRejection::Unreadable(error) => write!(f, "cannot read the image: {error}"),
214        }
215    }
216}
217
218/// Loads image bytes for display from a URL as written in the Markdown.
219/// Return `None` to fall back to `data:` URLs and files.
220pub type ImageLoader = Arc<dyn Fn(&str) -> Option<Vec<u8>> + Send + Sync>;
221
222// ------------------------------------------------------------------------
223// Formats
224// ------------------------------------------------------------------------
225
226/// MIME type from the first bytes of the file.
227pub(crate) fn sniff_mime(bytes: &[u8]) -> Option<&'static str> {
228    if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
229        Some("image/png")
230    } else if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
231        Some("image/jpeg")
232    } else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
233        Some("image/gif")
234    } else if bytes.len() >= 12 && &bytes[0..4] == b"RIFF" && &bytes[8..12] == b"WEBP" {
235        Some("image/webp")
236    } else if bytes.starts_with(b"BM") {
237        Some("image/bmp")
238    } else {
239        None
240    }
241}
242
243pub(crate) fn extension_for(mime: &str) -> &'static str {
244    match mime {
245        "image/png" => "png",
246        "image/jpeg" => "jpg",
247        "image/gif" => "gif",
248        "image/webp" => "webp",
249        "image/bmp" => "bmp",
250        _ => "bin",
251    }
252}
253
254/// Whether a dropped file looks like an image (by extension).
255pub(crate) fn is_image_path(path: &Path) -> bool {
256    path.extension()
257        .and_then(|extension| extension.to_str())
258        .map(|extension| extension.to_ascii_lowercase())
259        .is_some_and(|extension| {
260            matches!(
261                extension.as_str(),
262                "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp"
263            )
264        })
265}
266
267const BASE64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
268
269pub(crate) fn base64_encode(bytes: &[u8]) -> String {
270    let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
271    for chunk in bytes.chunks(3) {
272        let b = [
273            chunk[0],
274            chunk.get(1).copied().unwrap_or(0),
275            chunk.get(2).copied().unwrap_or(0),
276        ];
277        let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);
278        out.push(BASE64[(n >> 18) as usize & 63] as char);
279        out.push(BASE64[(n >> 12) as usize & 63] as char);
280        out.push(if chunk.len() > 1 {
281            BASE64[(n >> 6) as usize & 63] as char
282        } else {
283            '='
284        });
285        out.push(if chunk.len() > 2 {
286            BASE64[n as usize & 63] as char
287        } else {
288            '='
289        });
290    }
291    out
292}
293
294pub(crate) fn base64_decode(text: &str) -> Option<Vec<u8>> {
295    let value = |c: u8| BASE64.iter().position(|b| *b == c).map(|v| v as u32);
296    let digits: Vec<u8> = text
297        .bytes()
298        .filter(|b| !b.is_ascii_whitespace() && *b != b'=')
299        .collect();
300    let mut out = Vec::with_capacity(digits.len() * 3 / 4);
301    for chunk in digits.chunks(4) {
302        if chunk.len() == 1 {
303            return None;
304        }
305        let mut n = 0u32;
306        for (k, c) in chunk.iter().enumerate() {
307            n |= value(*c)? << (18 - 6 * k);
308        }
309        out.push((n >> 16) as u8);
310        if chunk.len() > 2 {
311            out.push((n >> 8) as u8);
312        }
313        if chunk.len() > 3 {
314            out.push(n as u8);
315        }
316    }
317    Some(out)
318}
319
320pub(crate) fn data_url(mime: &str, bytes: &[u8]) -> String {
321    format!("data:{mime};base64,{}", base64_encode(bytes))
322}
323
324pub(crate) fn decode_data_url(url: &str) -> Option<Vec<u8>> {
325    let rest = url.strip_prefix("data:")?;
326    let (header, data) = rest.split_once(',')?;
327    if header.ends_with(";base64") {
328        base64_decode(data)
329    } else {
330        None
331    }
332}
333
334// ------------------------------------------------------------------------
335// Placeholders
336// ------------------------------------------------------------------------
337
338pub(crate) fn pending_url(id: ImageId) -> String {
339    format!("{PENDING_PREFIX}{}", id.0)
340}
341
342pub(crate) fn pending_id(url: &str) -> Option<ImageId> {
343    url.strip_prefix(PENDING_PREFIX)?.parse().ok().map(ImageId)
344}
345
346/// One replaced placeholder: block, start, old length, new length.
347pub(crate) type PendingEdit = (usize, usize, usize, usize);
348
349/// Replaces the placeholder of `id` (`None`: every placeholder) with
350/// `![alt](url)`, or removes it when `replacement` is `None`.
351pub(crate) fn replace_pending(
352    doc: &mut Document,
353    id: Option<ImageId>,
354    replacement: Option<(&str, &str)>,
355) -> Vec<PendingEdit> {
356    let matches = |url: &str| match pending_id(url) {
357        Some(found) => id.is_none_or(|id| id == found),
358        None => false,
359    };
360    let mut edits = Vec::new();
361
362    for (index, block) in doc.blocks.iter_mut().enumerate() {
363        let mut i = 0;
364        while i < block.chars.len() {
365            let range = if block.leaf.is_plain() {
366                plain_placeholder(block, i, &matches)
367            } else {
368                styled_placeholder(block, i, &matches)
369            };
370            let Some((start, end)) = range else {
371                break;
372            };
373
374            let (chars, styles): (Vec<char>, Vec<Style>) = match replacement {
375                None => (Vec::new(), Vec::new()),
376                Some((url, alt)) if block.leaf.is_plain() => {
377                    let text = markdown::image_markdown(alt, url);
378                    let count = text.chars().count();
379                    (text.chars().collect(), vec![Style::default(); count])
380                }
381                Some((url, alt)) => {
382                    let style = Style {
383                        image: Some(Link::new(url, None)),
384                        ..block.styles[start].clone()
385                    };
386                    let chars: Vec<char> = if alt.is_empty() {
387                        vec![OBJ]
388                    } else {
389                        alt.chars().filter(|c| !c.is_control()).collect()
390                    };
391                    let count = chars.len();
392                    (chars, vec![style; count])
393                }
394            };
395            let new_len = chars.len();
396            block.chars.splice(start..end, chars);
397            block.styles.splice(start..end, styles);
398            edits.push((index, start, end - start, new_len));
399            i = start + new_len;
400        }
401    }
402    edits
403}
404
405/// The next formatted placeholder at or after `from`.
406fn styled_placeholder(
407    block: &Block,
408    from: usize,
409    matches: &impl Fn(&str) -> bool,
410) -> Option<(usize, usize)> {
411    let start = (from..block.chars.len()).find(|k| {
412        block.styles[*k]
413            .image
414            .as_ref()
415            .is_some_and(|link| matches(&link.url))
416    })?;
417    let link = block.styles[start].image.clone();
418    let mut end = start + 1;
419    while end < block.chars.len() && block.styles[end].image == link {
420        end += 1;
421    }
422    Some((start, end))
423}
424
425/// The next `![…](pending-image:N)` text at or after `from`.
426fn plain_placeholder(
427    block: &Block,
428    from: usize,
429    matches: &impl Fn(&str) -> bool,
430) -> Option<(usize, usize)> {
431    let chars = &block.chars;
432    let prefix: Vec<char> = format!("]({PENDING_PREFIX}").chars().collect();
433    let mut k = from;
434    while k + prefix.len() <= chars.len() {
435        if chars[k..k + prefix.len()] == prefix[..] {
436            let url_start = k + 2;
437            let mut url_end = url_start;
438            while url_end < chars.len() && chars[url_end] != ')' {
439                url_end += 1;
440            }
441            let url: String = chars[url_start..url_end].iter().collect();
442            let open = (from..k)
443                .rev()
444                .find(|s| chars[*s] == '!' && chars.get(s + 1) == Some(&'['));
445            if url_end < chars.len() && matches(&url) {
446                if let Some(start) = open {
447                    return Some((start, url_end + 1));
448                }
449            }
450        }
451        k += 1;
452    }
453    None
454}
455
456/// The document without placeholders (what is saved).
457pub(crate) fn without_pending(doc: &Document) -> Document {
458    let mut doc = doc.clone();
459    replace_pending(&mut doc, None, None);
460    doc
461}
462
463// ------------------------------------------------------------------------
464// Display
465// ------------------------------------------------------------------------
466
467/// A decoded image ready to draw.
468#[derive(Debug, Clone)]
469pub(crate) struct LoadedImage {
470    pub width: f32,
471    pub height: f32,
472    #[cfg(feature = "images")]
473    pub handle: iced::widget::image::Handle,
474}
475
476/// Images by URL, loaded once.
477#[derive(Default)]
478pub(crate) struct ImageCache {
479    entries: HashMap<String, Option<LoadedImage>>,
480}
481
482impl ImageCache {
483    pub fn clear(&mut self) {
484        self.entries.clear();
485    }
486
487    /// Registers bytes for a URL (pasted images, so they show at once).
488    pub fn insert_bytes(&mut self, url: &str, bytes: &[u8]) {
489        let loaded = decode(bytes);
490        self.entries.insert(url.to_string(), loaded);
491    }
492
493    pub fn get(
494        &mut self,
495        url: &str,
496        base: Option<&Path>,
497        loader: Option<&ImageLoader>,
498    ) -> Option<LoadedImage> {
499        if !cfg!(feature = "images") || url.starts_with(PENDING_PREFIX) {
500            return None;
501        }
502        if let Some(entry) = self.entries.get(url) {
503            return entry.clone();
504        }
505        let loaded = load_bytes(url, base, loader).and_then(|bytes| decode(&bytes));
506        self.entries.insert(url.to_string(), loaded.clone());
507        loaded
508    }
509}
510
511fn load_bytes(url: &str, base: Option<&Path>, loader: Option<&ImageLoader>) -> Option<Vec<u8>> {
512    if let Some(bytes) = loader.and_then(|loader| loader(url)) {
513        return Some(bytes);
514    }
515    if url.starts_with("data:") {
516        return decode_data_url(url);
517    }
518    read_file(url, base)
519}
520
521/// Local files (desktop only; the browser has no file system).
522#[cfg(not(target_arch = "wasm32"))]
523fn read_file(url: &str, base: Option<&Path>) -> Option<Vec<u8>> {
524    let path = url.strip_prefix("file://").unwrap_or(url);
525    if path.contains("://") {
526        return None; // remote URLs need a loader
527    }
528    let path = std::path::PathBuf::from(path.replace("%20", " "));
529    let path = if path.is_absolute() {
530        path
531    } else {
532        base?.join(path)
533    };
534    std::fs::read(path).ok()
535}
536
537#[cfg(target_arch = "wasm32")]
538fn read_file(_url: &str, _base: Option<&Path>) -> Option<Vec<u8>> {
539    None
540}
541
542#[cfg(feature = "images")]
543fn decode(bytes: &[u8]) -> Option<LoadedImage> {
544    let size = imagesize::blob_size(bytes).ok()?;
545    Some(LoadedImage {
546        width: size.width as f32,
547        height: size.height as f32,
548        handle: iced::widget::image::Handle::from_bytes(bytes.to_vec()),
549    })
550}
551
552#[cfg(not(feature = "images"))]
553fn decode(_bytes: &[u8]) -> Option<LoadedImage> {
554    None
555}
556
557// ------------------------------------------------------------------------
558// Clipboard (desktop only; Iced's clipboard is text only)
559// ------------------------------------------------------------------------
560
561/// An image on the clipboard, encoded as PNG.
562#[cfg(all(feature = "images", not(target_arch = "wasm32")))]
563pub(crate) fn clipboard_image() -> Option<Vec<u8>> {
564    let mut clipboard = arboard::Clipboard::new().ok()?;
565    let image = clipboard.get_image().ok()?;
566    encode_png(image.width as u32, image.height as u32, &image.bytes)
567}
568
569#[cfg(not(all(feature = "images", not(target_arch = "wasm32"))))]
570pub(crate) fn clipboard_image() -> Option<Vec<u8>> {
571    None
572}
573
574#[cfg(all(feature = "images", not(target_arch = "wasm32")))]
575fn encode_png(width: u32, height: u32, rgba: &[u8]) -> Option<Vec<u8>> {
576    let mut out = Vec::new();
577    {
578        let mut encoder = png::Encoder::new(&mut out, width, height);
579        encoder.set_color(png::ColorType::Rgba);
580        encoder.set_depth(png::BitDepth::Eight);
581        let mut writer = encoder.write_header().ok()?;
582        writer.write_image_data(rgba).ok()?;
583    }
584    Some(out)
585}
586
587#[cfg(test)]
588mod tests {
589    use super::*;
590    use crate::doc::Leaf;
591    use crate::markdown::{parse, serialize};
592
593    #[test]
594    fn base64_round_trip() {
595        for bytes in [&b""[..], b"f", b"fo", b"foo", b"foob", b"fooba", b"foobar"] {
596            let encoded = base64_encode(bytes);
597            assert_eq!(base64_decode(&encoded).unwrap(), bytes);
598        }
599        assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy");
600        assert_eq!(base64_encode(b"fo"), "Zm8=");
601    }
602
603    #[test]
604    fn detects_types_and_data_urls() {
605        assert_eq!(sniff_mime(b"\x89PNG\r\n\x1a\nrest"), Some("image/png"));
606        assert_eq!(sniff_mime(&[0xFF, 0xD8, 0xFF, 0xE0]), Some("image/jpeg"));
607        assert_eq!(sniff_mime(b"RIFF1234WEBPVP8 "), Some("image/webp"));
608        assert_eq!(sniff_mime(b"%PDF"), None);
609        let url = data_url("image/png", b"\x89PNG");
610        assert_eq!(decode_data_url(&url).unwrap(), b"\x89PNG");
611        assert!(is_image_path(Path::new("a/B.JPG")));
612        assert!(!is_image_path(Path::new("notes.md")));
613    }
614
615    #[test]
616    fn resolves_and_cancels_placeholders() {
617        let id = ImageId(7);
618        let text = format!("before ![x]({}) after", pending_url(id));
619        let mut doc = parse(&text);
620        replace_pending(&mut doc, Some(id), Some(("assets/a.png", "photo")));
621        assert_eq!(serialize(&doc), "before ![photo](assets/a.png) after\n");
622
623        let mut doc = parse(&text);
624        replace_pending(&mut doc, Some(ImageId(8)), None);
625        assert!(serialize(&doc).contains("pending-image:7"));
626        replace_pending(&mut doc, Some(id), None);
627        assert_eq!(serialize(&doc), "before  after\n");
628    }
629
630    #[test]
631    fn placeholders_in_source_lines() {
632        let id = ImageId(3);
633        let line = format!("x ![{PENDING_ALT}]({}) y", pending_url(id));
634        let mut block = Block::new(Vec::new(), Leaf::Line);
635        for c in line.chars() {
636            block.push(c, Style::default());
637        }
638        let mut doc = Document {
639            blocks: vec![block],
640        };
641        replace_pending(&mut doc, Some(id), Some(("a b.png", "")));
642        assert_eq!(doc.blocks[0].text(), "x ![](<a b.png>) y");
643    }
644
645    #[test]
646    fn saved_documents_have_no_placeholders() {
647        let doc = parse(&format!("![x]({})\n\ntext", pending_url(ImageId(1))));
648        assert_eq!(serialize(&without_pending(&doc)), "text\n");
649    }
650}