Skip to main content

soothe_client/appkit/
attachments.rs

1//! Attachment compaction helpers for turn input payloads.
2
3use serde_json::{Map, Value};
4
5/// Controls [`compact_image_attachment`]. Zero fields use defaults.
6#[derive(Debug, Clone, Copy, Default)]
7pub struct CompactImageOptions {
8    /// Maximum width or height in pixels. Default 768.
9    pub max_dim: u32,
10    /// JPEG encode quality (1–100). Default 85.
11    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/// Downscales `image/*` payloads when either dimension exceeds [`CompactImageOptions::max_dim`].
31///
32/// Non-images and decode failures pass through unchanged. PNG stays PNG; other image types
33/// re-encode as JPEG when the `image` feature is enabled.
34#[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/// Pass-through stub when the `image` feature is disabled.
120#[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
129/// Applies [`compact_image_attachment`] to each attachment map that carries `mime_type` + `data`.
130pub 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}