1use 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
24pub(crate) const PENDING_PREFIX: &str = "pending-image:";
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
29pub enum ImageMode {
30 #[default]
32 Disabled,
33 Embed,
35 Delegate,
38}
39
40#[derive(Debug, Clone, PartialEq)]
42pub struct ImageSettings {
43 pub mode: ImageMode,
44 pub max_bytes: usize,
46 pub accept: Vec<String>,
48 pub drop: bool,
50}
51
52impl Default for ImageSettings {
53 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 pub fn embed() -> Self {
74 Self {
75 mode: ImageMode::Embed,
76 ..Self::default()
77 }
78 }
79
80 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
114pub enum ImageSource {
115 Paste,
117 Drop,
119 Insert,
122}
123
124#[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#[derive(Clone, PartialEq)]
142pub struct PastedImage {
143 pub id: ImageId,
144 pub bytes: Arc<[u8]>,
146 pub mime: &'static str,
148 pub source: ImageSource,
149 pub name: Option<String>,
151}
152
153impl PastedImage {
154 pub fn extension(&self) -> &'static str {
156 extension_for(self.mime)
157 }
158
159 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#[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
218impl ImageRejection {
219 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
242pub type ImageLoader = Arc<dyn Fn(&str) -> Option<Vec<u8>> + Send + Sync>;
245
246pub(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
278pub(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
358pub(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
370pub(crate) type PendingEdit = (usize, usize, usize, usize);
372
373pub(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
429fn 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
449fn 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
480pub(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#[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#[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 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#[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; }
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#[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  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  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  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  y");
667 }
668
669 #[test]
670 fn saved_documents_have_no_placeholders() {
671 let doc = parse(&format!("\n\ntext", pending_url(ImageId(1))));
672 assert_eq!(serialize(&without_pending(&doc)), "text\n");
673 }
674}