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