soothe_client/appkit/
attachments.rs1use serde_json::{Map, Value};
4
5#[derive(Debug, Clone, Copy, Default)]
7pub struct CompactImageOptions {
8 pub max_dim: u32,
10 pub jpeg_quality: u8,
12}
13
14#[cfg(feature = "image")]
15fn compact_defaults(opts: Option<&CompactImageOptions>) -> (u32, u8) {
16 let mut max_dim = 768u32;
17 let mut quality = 85u8;
18 let Some(opts) = opts else {
19 return (max_dim, quality);
20 };
21 if opts.max_dim > 0 {
22 max_dim = opts.max_dim;
23 }
24 if opts.jpeg_quality > 0 {
25 quality = opts.jpeg_quality;
26 }
27 (max_dim, quality)
28}
29
30#[cfg(feature = "image")]
35pub fn compact_image_attachment(
36 mime_type: &str,
37 data_b64: &str,
38 opts: Option<&CompactImageOptions>,
39) -> (String, String) {
40 use base64::Engine;
41 use image::codecs::jpeg::JpegEncoder;
42 use image::codecs::png::PngEncoder;
43 use image::imageops::FilterType;
44 use image::ExtendedColorType;
45 use image::ImageEncoder;
46 use image::ImageReader;
47 use std::io::Cursor;
48
49 if data_b64.is_empty() || !mime_type.starts_with("image/") {
50 return (mime_type.to_string(), data_b64.to_string());
51 }
52
53 let raw = match base64::engine::general_purpose::STANDARD.decode(data_b64) {
54 Ok(v) if !v.is_empty() => v,
55 _ => return (mime_type.to_string(), data_b64.to_string()),
56 };
57
58 let img = match ImageReader::new(Cursor::new(&raw))
59 .with_guessed_format()
60 .ok()
61 .and_then(|r| r.decode().ok())
62 {
63 Some(img) => img,
64 None => return (mime_type.to_string(), data_b64.to_string()),
65 };
66
67 let (max_dim, quality) = compact_defaults(opts);
68 let (w, h) = (img.width(), img.height());
69 if w <= max_dim && h <= max_dim {
70 return (mime_type.to_string(), data_b64.to_string());
71 }
72
73 let (nw, nh) = if w >= h {
74 if w > max_dim {
75 let nh = (h as u64 * max_dim as u64 / w as u64).max(1) as u32;
76 (max_dim, nh)
77 } else {
78 (w, h)
79 }
80 } else if h > max_dim {
81 let nw = (w as u64 * max_dim as u64 / h as u64).max(1) as u32;
82 (nw, max_dim)
83 } else {
84 (w, h)
85 };
86
87 let resized = img.resize_exact(nw, nh, FilterType::CatmullRom);
88 let rgba = resized.to_rgba8();
89 let (nw, nh) = (rgba.width(), rgba.height());
90 let pixels = rgba.as_raw();
91 let mut buf = Vec::new();
92
93 if mime_type == "image/png" {
94 if PngEncoder::new(&mut buf)
95 .write_image(pixels, nw, nh, ExtendedColorType::Rgba8)
96 .is_err()
97 {
98 return (mime_type.to_string(), data_b64.to_string());
99 }
100 return (
101 mime_type.to_string(),
102 base64::engine::general_purpose::STANDARD.encode(buf),
103 );
104 }
105
106 if JpegEncoder::new_with_quality(&mut buf, quality)
107 .encode(pixels, nw, nh, ExtendedColorType::Rgba8)
108 .is_err()
109 {
110 return (mime_type.to_string(), data_b64.to_string());
111 }
112
113 (
114 "image/jpeg".to_string(),
115 base64::engine::general_purpose::STANDARD.encode(buf),
116 )
117}
118
119#[cfg(not(feature = "image"))]
121pub fn compact_image_attachment(
122 mime_type: &str,
123 data_b64: &str,
124 _opts: Option<&CompactImageOptions>,
125) -> (String, String) {
126 (mime_type.to_string(), data_b64.to_string())
127}
128
129pub fn compact_attachments(
131 atts: &[Map<String, Value>],
132 opts: Option<&CompactImageOptions>,
133) -> Vec<Map<String, Value>> {
134 if atts.is_empty() {
135 return atts.to_vec();
136 }
137 atts.iter()
138 .map(|att| {
139 let mut cp = att.clone();
140 let mime = cp
141 .get("mime_type")
142 .and_then(|v| v.as_str())
143 .unwrap_or("")
144 .to_string();
145 let data = cp
146 .get("data")
147 .and_then(|v| v.as_str())
148 .unwrap_or("")
149 .to_string();
150 if !mime.is_empty() && !data.is_empty() {
151 let (out_mime, out_data) = compact_image_attachment(&mime, &data, opts);
152 cp.insert("mime_type".to_string(), Value::String(out_mime));
153 cp.insert("data".to_string(), Value::String(out_data));
154 }
155 cp
156 })
157 .collect()
158}