Skip to main content

pi/core/platform/
image.rs

1//! Image MIME sniffing and the inline-image pipeline facade.
2//!
3//! Ports the *surface* of `.references/pi/packages/coding-agent/src/utils/`
4//! `{mime.ts, image-process.ts, image-convert.ts}`. The decoder, EXIF
5//! orientation, resize, and encode ladder live in
6//! [`crate::core::tools::read`] (the read-tool pipeline, which is the single
7//! canonical home for image decoding in this crate). This module only exposes
8//! the clipboard- and display-facing helpers and delegates every decode to
9//! that pipeline, so the `image` decoder is never duplicated.
10
11use crate::core::tools::read::{
12    ProcessImageResult as ReadProcessImageResult, convert_image_bytes_to_png,
13    detect_supported_image_mime_type, process_image_bytes,
14};
15
16/// Supported inline image MIME kinds, keyed by magic bytes.
17#[derive(Copy, Clone, Debug, Eq, PartialEq)]
18pub enum ImageMime {
19    /// `image/jpeg`
20    Jpeg,
21    /// `image/png`
22    Png,
23    /// `image/gif`
24    Gif,
25    /// `image/webp`
26    Webp,
27    /// `image/bmp`
28    Bmp,
29}
30
31impl ImageMime {
32    /// The canonical MIME type string.
33    #[must_use]
34    pub const fn mime(self) -> &'static str {
35        match self {
36            Self::Jpeg => "image/jpeg",
37            Self::Png => "image/png",
38            Self::Gif => "image/gif",
39            Self::Webp => "image/webp",
40            Self::Bmp => "image/bmp",
41        }
42    }
43
44    /// Parse a canonical MIME string into a kind.
45    #[must_use]
46    pub fn from_canonical(mime: &str) -> Option<Self> {
47        Some(match mime {
48            "image/jpeg" | "image/jpg" => Self::Jpeg,
49            "image/png" => Self::Png,
50            "image/gif" => Self::Gif,
51            "image/webp" => Self::Webp,
52            "image/bmp" => Self::Bmp,
53            _ => return None,
54        })
55    }
56}
57
58/// File extension for an image MIME, matching the TypeScript
59/// `extensionForImageMimeType`. Returns `"jpg"` (not `"jpeg"`) for JPEG.
60///
61/// This is a pure string mapping with no decoding, so it is safe to keep here
62/// alongside the delegating helpers.
63#[must_use]
64pub fn extension_for_image_mime(mime: &str) -> Option<&'static str> {
65    let base = base_mime(mime);
66    match base.as_str() {
67        "image/png" => Some("png"),
68        "image/jpeg" | "image/jpg" => Some("jpg"),
69        "image/webp" => Some("webp"),
70        "image/gif" => Some("gif"),
71        "image/bmp" => Some("bmp"),
72        _ => None,
73    }
74}
75
76/// Detect a supported image MIME from magic bytes.
77///
78/// Delegates to [`crate::core::tools::read::detect_supported_image_mime_type`]
79/// (the read-tool sniffer), rejecting animated PNG (acTL) and the JPEG Hi/Co
80/// variant (third byte `0xF7`).
81#[must_use]
82pub fn detect_supported_image_mime(bytes: &[u8]) -> Option<ImageMime> {
83    detect_supported_image_mime_type(bytes).and_then(|mime| ImageMime::from_canonical(&mime))
84}
85
86/// Convert an image byte stream to PNG bytes (with orientation applied).
87///
88/// Delegates to [`crate::core::tools::read::convert_image_bytes_to_png`] so the
89/// decoder is not duplicated. Returns `None` when the bytes cannot be decoded.
90#[must_use]
91pub fn convert_to_png(bytes: &[u8]) -> Option<Vec<u8>> {
92    convert_image_bytes_to_png(bytes)
93}
94
95/// Outcome of [`process_image`].
96#[derive(Clone, Debug)]
97pub enum ProcessImageResult {
98    /// Image was normalized (and optionally resized) successfully.
99    Ok {
100        /// Base64-encoded payload.
101        data: String,
102        /// Canonical MIME type.
103        mime: String,
104        /// Human-readable annotations (conversion note, dimension note).
105        hints: Vec<String>,
106    },
107    /// Image could not be converted or resized; a model-readable omission note.
108    Omitted(String),
109}
110
111/// Full image pipeline mirroring `processImage` in `image-process.ts`.
112///
113/// Delegates to [`crate::core::tools::read::process_image_bytes`]: normalize
114/// the MIME (keep supported types, else convert to PNG), then resize when
115/// `auto_resize` is on. Produces conversion and dimension hints in the same
116/// order as the reference.
117#[must_use]
118pub fn process_image(bytes: &[u8], mime: &str, auto_resize: bool) -> ProcessImageResult {
119    match process_image_bytes(bytes, mime, auto_resize) {
120        ReadProcessImageResult::Ok(processed) => ProcessImageResult::Ok {
121            data: processed.data,
122            mime: processed.mime_type,
123            hints: processed.hints,
124        },
125        ReadProcessImageResult::Failed(error) => ProcessImageResult::Omitted(error.message),
126    }
127}
128
129/// Normalize a MIME string to its base form (lowercased, parameters stripped).
130fn base_mime(mime: &str) -> String {
131    mime.split(';')
132        .next()
133        .unwrap_or(mime)
134        .trim()
135        .to_ascii_lowercase()
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use image::{GenericImageView, ImageFormat};
142    use std::io::Cursor;
143    type TestResult<T = ()> = Result<T, Box<dyn std::error::Error>>;
144
145    fn solid_png(width: u32, height: u32) -> TestResult<Vec<u8>> {
146        let img = image::RgbaImage::from_pixel(width, height, image::Rgba([10, 20, 30, 255]));
147        let mut buf = Vec::new();
148        img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Png)?;
149        Ok(buf)
150    }
151
152    fn solid_jpeg(width: u32, height: u32) -> TestResult<Vec<u8>> {
153        let img = image::RgbImage::from_pixel(width, height, image::Rgb([10, 20, 30]));
154        let mut buf = Vec::new();
155        let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, 90);
156        encoder.encode_image(&image::DynamicImage::ImageRgb8(img))?;
157        Ok(buf)
158    }
159
160    #[test]
161    fn detects_png_jpeg_gif_webp_signatures() -> TestResult {
162        let png = solid_png(2, 2)?;
163        assert_eq!(detect_supported_image_mime(&png), Some(ImageMime::Png));
164        let jpeg = solid_jpeg(2, 2)?;
165        assert_eq!(detect_supported_image_mime(&jpeg), Some(ImageMime::Jpeg));
166        let gif = b"GIF89a...";
167        assert_eq!(detect_supported_image_mime(gif), Some(ImageMime::Gif));
168        let mut webp = b"RIFF\x00\x00\x00\x00WEBP".to_vec();
169        webp.extend_from_slice(b"VP8 ");
170        assert_eq!(detect_supported_image_mime(&webp), Some(ImageMime::Webp));
171        Ok(())
172    }
173
174    #[test]
175    fn rejects_jpeg_hico_marker() {
176        // FF D8 FF F7 is the Hi/Co JPEG variant; rejected.
177        assert_eq!(detect_supported_image_mime(&[0xFF, 0xD8, 0xFF, 0xF7]), None);
178    }
179
180    #[test]
181    fn extension_for_mime_matches_ts() {
182        assert_eq!(extension_for_image_mime("image/png"), Some("png"));
183        assert_eq!(extension_for_image_mime("image/jpeg"), Some("jpg"));
184        assert_eq!(
185            extension_for_image_mime("image/jpeg; charset=binary"),
186            Some("jpg")
187        );
188        assert_eq!(extension_for_image_mime("image/x-foo"), None);
189    }
190
191    #[test]
192    fn convert_to_png_roundtrips_dimensions() -> TestResult {
193        let png = solid_png(3, 5)?;
194        let out = convert_to_png(&png)
195            .ok_or_else(|| std::io::Error::other("PNG conversion was omitted"))?;
196        let img = image::load_from_memory(&out)?;
197        assert_eq!(img.dimensions(), (3, 5));
198        Ok(())
199    }
200
201    #[test]
202    fn process_image_normalizes_bmp_to_png() -> TestResult {
203        let mut bmp_buf = Vec::new();
204        let img = image::RgbImage::from_pixel(4, 4, image::Rgb([5, 6, 7]));
205        image::DynamicImage::ImageRgb8(img)
206            .write_to(&mut Cursor::new(&mut bmp_buf), ImageFormat::Bmp)?;
207        match process_image(&bmp_buf, "image/bmp", false) {
208            ProcessImageResult::Ok { data, mime, hints } => {
209                assert_eq!(mime, "image/png");
210                assert!(!data.is_empty());
211                assert!(
212                    hints
213                        .iter()
214                        .any(|h| h.contains("converted from image/bmp to image/png"))
215                );
216                Ok(())
217            }
218            ProcessImageResult::Omitted(reason) => {
219                Err(std::io::Error::other(format!("expected Ok, got Omitted({reason})")).into())
220            }
221        }
222    }
223
224    #[test]
225    fn process_image_keeps_supported_png() -> TestResult {
226        let png = solid_png(2, 2)?;
227        match process_image(&png, "image/png", false) {
228            ProcessImageResult::Ok { mime, .. } => {
229                assert_eq!(mime, "image/png");
230                Ok(())
231            }
232            ProcessImageResult::Omitted(reason) => {
233                Err(std::io::Error::other(format!("expected Ok, got Omitted({reason})")).into())
234            }
235        }
236    }
237}