1use 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
23pub(crate) const PENDING_PREFIX: &str = "pending-image:";
25pub(crate) const PENDING_ALT: &str = "Saving image\u{2026}";
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
30pub enum ImageMode {
31 #[default]
33 Disabled,
34 Embed,
36 Delegate,
39}
40
41#[derive(Debug, Clone, PartialEq)]
43pub struct ImageSettings {
44 pub mode: ImageMode,
45 pub max_bytes: usize,
47 pub accept: Vec<String>,
49 pub drop: bool,
51}
52
53impl Default for ImageSettings {
54 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 pub fn embed() -> Self {
75 Self {
76 mode: ImageMode::Embed,
77 ..Self::default()
78 }
79 }
80
81 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
115pub enum ImageSource {
116 Paste,
118 Drop,
120 Insert,
123}
124
125#[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#[derive(Clone, PartialEq)]
143pub struct PastedImage {
144 pub id: ImageId,
145 pub bytes: Arc<[u8]>,
147 pub mime: &'static str,
149 pub source: ImageSource,
150 pub name: Option<String>,
152}
153
154impl PastedImage {
155 pub fn extension(&self) -> &'static str {
157 extension_for(self.mime)
158 }
159
160 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#[derive(Debug, Clone, PartialEq, Eq)]
187pub enum ImageRejection {
188 Disabled,
190 UnsupportedType(String),
192 TooLarge {
193 size: usize,
194 max: usize,
195 },
196 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
218pub type ImageLoader = Arc<dyn Fn(&str) -> Option<Vec<u8>> + Send + Sync>;
221
222pub(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
254pub(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
334pub(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
346pub(crate) type PendingEdit = (usize, usize, usize, usize);
348
349pub(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
405fn 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
425fn 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
456pub(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#[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#[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 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#[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; }
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#[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  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  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  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  y");
643 }
644
645 #[test]
646 fn saved_documents_have_no_placeholders() {
647 let doc = parse(&format!("\n\ntext", pending_url(ImageId(1))));
648 assert_eq!(serialize(&without_pending(&doc)), "text\n");
649 }
650}