Skip to main content

cdx_core/asset/
image.rs

1//! Image asset types.
2
3use serde::{Deserialize, Serialize};
4
5use crate::DocumentId;
6
7/// Image format enumeration.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
9#[serde(rename_all = "lowercase")]
10pub enum ImageFormat {
11    /// AVIF format (required, preferred for raster).
12    Avif,
13    /// WebP format (required).
14    WebP,
15    /// PNG format (required).
16    Png,
17    /// JPEG format (required).
18    Jpeg,
19    /// SVG format (required for vector).
20    Svg,
21}
22
23impl ImageFormat {
24    /// Get the file extension for this format.
25    #[must_use]
26    pub const fn extension(&self) -> &'static str {
27        match self {
28            Self::Avif => "avif",
29            Self::WebP => "webp",
30            Self::Png => "png",
31            Self::Jpeg => "jpg",
32            Self::Svg => "svg",
33        }
34    }
35
36    /// Get the MIME type for this format.
37    #[must_use]
38    pub const fn mime_type(&self) -> &'static str {
39        match self {
40            Self::Avif => "image/avif",
41            Self::WebP => "image/webp",
42            Self::Png => "image/png",
43            Self::Jpeg => "image/jpeg",
44            Self::Svg => "image/svg+xml",
45        }
46    }
47
48    /// Check if this is a vector format.
49    #[must_use]
50    pub const fn is_vector(&self) -> bool {
51        matches!(self, Self::Svg)
52    }
53
54    /// Check if this is a raster format.
55    #[must_use]
56    pub const fn is_raster(&self) -> bool {
57        !self.is_vector()
58    }
59}
60
61impl std::fmt::Display for ImageFormat {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        write!(f, "{}", self.extension())
64    }
65}
66
67/// A resolution variant of an image for responsive display.
68#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
69#[serde(rename_all = "camelCase")]
70pub struct ImageVariant {
71    /// Path within the archive.
72    pub path: String,
73
74    /// Content hash for verification.
75    pub hash: DocumentId,
76
77    /// Image width in pixels.
78    pub width: u32,
79
80    /// Image height in pixels.
81    pub height: u32,
82
83    /// Scale factor (e.g., 1.0 for 1x, 2.0 for 2x, 3.0 for 3x).
84    pub scale: f32,
85
86    /// File size in bytes.
87    pub size: u64,
88}
89
90impl ImageVariant {
91    /// Create a new image variant.
92    #[must_use]
93    pub fn new(path: impl Into<String>, width: u32, height: u32, scale: f32) -> Self {
94        Self {
95            path: path.into(),
96            hash: DocumentId::pending(),
97            width,
98            height,
99            scale,
100            size: 0,
101        }
102    }
103
104    /// Set the content hash.
105    #[must_use]
106    pub fn with_hash(mut self, hash: DocumentId) -> Self {
107        self.hash = hash;
108        self
109    }
110
111    /// Set the file size.
112    #[must_use]
113    pub const fn with_size(mut self, size: u64) -> Self {
114        self.size = size;
115        self
116    }
117
118    /// Create a 1x variant.
119    #[must_use]
120    pub fn scale_1x(path: impl Into<String>, width: u32, height: u32) -> Self {
121        Self::new(path, width, height, 1.0)
122    }
123
124    /// Create a 2x (Retina) variant.
125    #[must_use]
126    pub fn scale_2x(path: impl Into<String>, width: u32, height: u32) -> Self {
127        Self::new(path, width, height, 2.0)
128    }
129
130    /// Create a 3x variant.
131    #[must_use]
132    pub fn scale_3x(path: impl Into<String>, width: u32, height: u32) -> Self {
133        Self::new(path, width, height, 3.0)
134    }
135}
136
137/// An image asset embedded in a Codex document.
138#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
139#[serde(rename_all = "camelCase")]
140pub struct ImageAsset {
141    /// Unique identifier for the image.
142    pub id: String,
143
144    /// Path within the archive (e.g., "assets/images/logo.png").
145    pub path: String,
146
147    /// Content hash for verification.
148    pub hash: DocumentId,
149
150    /// Image format.
151    pub format: ImageFormat,
152
153    /// File size in bytes.
154    pub size: u64,
155
156    /// Image width in pixels (for raster images).
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub width: Option<u32>,
159
160    /// Image height in pixels (for raster images).
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub height: Option<u32>,
163
164    /// Alternative text for accessibility.
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub alt: Option<String>,
167
168    /// Optional title/caption.
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub title: Option<String>,
171
172    /// Source attribution or copyright.
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub attribution: Option<String>,
175
176    /// Resolution variants for responsive images.
177    ///
178    /// Each variant represents the same image at a different resolution,
179    /// typically for different screen densities (1x, 2x, 3x).
180    #[serde(default, skip_serializing_if = "Vec::is_empty")]
181    pub variants: Vec<ImageVariant>,
182}
183
184impl ImageAsset {
185    /// Create a new image asset.
186    #[must_use]
187    pub fn new(id: impl Into<String>, format: ImageFormat) -> Self {
188        let id = id.into();
189        let path = format!("assets/images/{}.{}", id, format.extension());
190        Self {
191            id,
192            path,
193            hash: DocumentId::pending(),
194            format,
195            size: 0,
196            width: None,
197            height: None,
198            alt: None,
199            title: None,
200            attribution: None,
201            variants: Vec::new(),
202        }
203    }
204
205    /// Set the content hash.
206    #[must_use]
207    pub fn with_hash(mut self, hash: DocumentId) -> Self {
208        self.hash = hash;
209        self
210    }
211
212    /// Set the file size.
213    #[must_use]
214    pub const fn with_size(mut self, size: u64) -> Self {
215        self.size = size;
216        self
217    }
218
219    /// Set the dimensions.
220    #[must_use]
221    pub fn with_dimensions(mut self, width: u32, height: u32) -> Self {
222        self.width = Some(width);
223        self.height = Some(height);
224        self
225    }
226
227    /// Set the alternative text.
228    #[must_use]
229    pub fn with_alt(mut self, alt: impl Into<String>) -> Self {
230        self.alt = Some(alt.into());
231        self
232    }
233
234    /// Set the title.
235    #[must_use]
236    pub fn with_title(mut self, title: impl Into<String>) -> Self {
237        self.title = Some(title.into());
238        self
239    }
240
241    /// Set the attribution.
242    #[must_use]
243    pub fn with_attribution(mut self, attribution: impl Into<String>) -> Self {
244        self.attribution = Some(attribution.into());
245        self
246    }
247
248    /// Set a custom path.
249    #[must_use]
250    pub fn with_path(mut self, path: impl Into<String>) -> Self {
251        self.path = path.into();
252        self
253    }
254
255    /// Add a resolution variant.
256    #[must_use]
257    pub fn with_variant(mut self, variant: ImageVariant) -> Self {
258        self.variants.push(variant);
259        self
260    }
261
262    /// Add multiple resolution variants.
263    #[must_use]
264    pub fn with_variants(mut self, variants: Vec<ImageVariant>) -> Self {
265        self.variants = variants;
266        self
267    }
268
269    /// Check if this image has resolution variants.
270    #[must_use]
271    pub fn has_variants(&self) -> bool {
272        !self.variants.is_empty()
273    }
274
275    /// Get the variant for a specific scale, if available.
276    #[must_use]
277    pub fn variant_for_scale(&self, scale: f32) -> Option<&ImageVariant> {
278        self.variants
279            .iter()
280            .find(|v| (v.scale - scale).abs() < 0.01)
281    }
282
283    /// Get the best variant for a given target width.
284    ///
285    /// Returns the smallest variant that is at least as wide as the target,
286    /// or the largest available variant if none are wide enough.
287    #[must_use]
288    pub fn best_variant_for_width(&self, target_width: u32) -> Option<&ImageVariant> {
289        if self.variants.is_empty() {
290            return None;
291        }
292
293        // Find smallest variant >= target width
294        let mut candidates: Vec<_> = self
295            .variants
296            .iter()
297            .filter(|v| v.width >= target_width)
298            .collect();
299
300        if candidates.is_empty() {
301            // No variant is wide enough, return the largest
302            self.variants.iter().max_by_key(|v| v.width)
303        } else {
304            // Return the smallest that fits
305            candidates.sort_by_key(|v| v.width);
306            candidates.first().copied()
307        }
308    }
309}
310
311impl super::Asset for ImageAsset {
312    fn id(&self) -> &str {
313        &self.id
314    }
315
316    fn path(&self) -> &str {
317        &self.path
318    }
319
320    fn hash(&self) -> &DocumentId {
321        &self.hash
322    }
323
324    fn size(&self) -> u64 {
325        self.size
326    }
327
328    fn mime_type(&self) -> &str {
329        self.format.mime_type()
330    }
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336
337    #[test]
338    fn test_image_format_extension() {
339        assert_eq!(ImageFormat::Avif.extension(), "avif");
340        assert_eq!(ImageFormat::WebP.extension(), "webp");
341        assert_eq!(ImageFormat::Png.extension(), "png");
342        assert_eq!(ImageFormat::Jpeg.extension(), "jpg");
343        assert_eq!(ImageFormat::Svg.extension(), "svg");
344    }
345
346    #[test]
347    fn test_image_format_mime_type() {
348        assert_eq!(ImageFormat::Avif.mime_type(), "image/avif");
349        assert_eq!(ImageFormat::Svg.mime_type(), "image/svg+xml");
350    }
351
352    #[test]
353    fn test_image_format_vector_raster() {
354        assert!(ImageFormat::Svg.is_vector());
355        assert!(!ImageFormat::Png.is_vector());
356        assert!(ImageFormat::Png.is_raster());
357    }
358
359    #[test]
360    fn test_image_asset_new() {
361        let image = ImageAsset::new("logo", ImageFormat::Png);
362        assert_eq!(image.id, "logo");
363        assert_eq!(image.path, "assets/images/logo.png");
364        assert_eq!(image.format, ImageFormat::Png);
365    }
366
367    #[test]
368    fn test_image_asset_builder() {
369        let image = ImageAsset::new("photo", ImageFormat::Jpeg)
370            .with_dimensions(1920, 1080)
371            .with_alt("A beautiful sunset")
372            .with_size(524_288);
373
374        assert_eq!(image.width, Some(1920));
375        assert_eq!(image.height, Some(1080));
376        assert_eq!(image.alt, Some("A beautiful sunset".to_string()));
377        assert_eq!(image.size, 524_288);
378    }
379
380    #[test]
381    fn test_image_asset_serialization() {
382        let image = ImageAsset::new("test", ImageFormat::Png)
383            .with_dimensions(100, 100)
384            .with_alt("Test image");
385
386        let json = serde_json::to_string_pretty(&image).unwrap();
387        assert!(json.contains(r#""id": "test""#));
388        assert!(json.contains(r#""format": "png""#));
389        assert!(json.contains(r#""width": 100"#));
390
391        let deserialized: ImageAsset = serde_json::from_str(&json).unwrap();
392        assert_eq!(deserialized.id, image.id);
393        assert_eq!(deserialized.format, image.format);
394    }
395
396    #[test]
397    fn test_image_variant_creation() {
398        let variant = ImageVariant::new("assets/images/logo@2x.png", 400, 200, 2.0).with_size(8192);
399
400        assert_eq!(variant.width, 400);
401        assert_eq!(variant.height, 200);
402        assert!((variant.scale - 2.0).abs() < f32::EPSILON);
403        assert_eq!(variant.size, 8192);
404    }
405
406    #[test]
407    fn test_image_variant_scale_helpers() {
408        let v1x = ImageVariant::scale_1x("logo.png", 100, 50);
409        let v2x = ImageVariant::scale_2x("logo@2x.png", 200, 100);
410        let v3x = ImageVariant::scale_3x("logo@3x.png", 300, 150);
411
412        assert!((v1x.scale - 1.0).abs() < f32::EPSILON);
413        assert!((v2x.scale - 2.0).abs() < f32::EPSILON);
414        assert!((v3x.scale - 3.0).abs() < f32::EPSILON);
415    }
416
417    #[test]
418    fn test_image_asset_with_variants() {
419        let image = ImageAsset::new("logo", ImageFormat::Png)
420            .with_dimensions(100, 50)
421            .with_variant(ImageVariant::scale_1x("assets/images/logo.png", 100, 50))
422            .with_variant(ImageVariant::scale_2x(
423                "assets/images/logo@2x.png",
424                200,
425                100,
426            ));
427
428        assert!(image.has_variants());
429        assert_eq!(image.variants.len(), 2);
430    }
431
432    #[test]
433    fn test_image_variant_for_scale() {
434        let image = ImageAsset::new("logo", ImageFormat::Png)
435            .with_variant(ImageVariant::scale_1x("logo.png", 100, 50))
436            .with_variant(ImageVariant::scale_2x("logo@2x.png", 200, 100));
437
438        assert!(image.variant_for_scale(1.0).is_some());
439        assert!(image.variant_for_scale(2.0).is_some());
440        assert!(image.variant_for_scale(3.0).is_none());
441    }
442
443    #[test]
444    fn test_image_best_variant_for_width() {
445        let image = ImageAsset::new("logo", ImageFormat::Png)
446            .with_variant(ImageVariant::scale_1x("logo.png", 100, 50))
447            .with_variant(ImageVariant::scale_2x("logo@2x.png", 200, 100))
448            .with_variant(ImageVariant::scale_3x("logo@3x.png", 300, 150));
449
450        // Should return 1x (100) - smallest that fits
451        let best = image.best_variant_for_width(80);
452        assert!(best.is_some());
453        assert_eq!(best.unwrap().width, 100);
454
455        // Should return 2x (200) - smallest that fits
456        let best = image.best_variant_for_width(150);
457        assert!(best.is_some());
458        assert_eq!(best.unwrap().width, 200);
459
460        // Should return 3x (300) - only one that fits
461        let best = image.best_variant_for_width(250);
462        assert!(best.is_some());
463        assert_eq!(best.unwrap().width, 300);
464
465        // Should return 3x (300) - largest available
466        let best = image.best_variant_for_width(400);
467        assert!(best.is_some());
468        assert_eq!(best.unwrap().width, 300);
469    }
470
471    #[test]
472    fn test_image_variant_serialization() {
473        let image = ImageAsset::new("responsive", ImageFormat::Png)
474            .with_dimensions(100, 50)
475            .with_variant(ImageVariant::scale_2x(
476                "assets/images/responsive@2x.png",
477                200,
478                100,
479            ));
480
481        let json = serde_json::to_string_pretty(&image).unwrap();
482        assert!(json.contains("variants"));
483        assert!(json.contains("@2x"));
484
485        let deserialized: ImageAsset = serde_json::from_str(&json).unwrap();
486        assert_eq!(deserialized.variants.len(), 1);
487        assert_eq!(deserialized.variants[0].width, 200);
488    }
489}