1use serde::{Deserialize, Serialize};
4
5use crate::DocumentId;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
9#[serde(rename_all = "lowercase")]
10pub enum ImageFormat {
11 Avif,
13 WebP,
15 Png,
17 Jpeg,
19 Svg,
21}
22
23impl ImageFormat {
24 #[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 #[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 #[must_use]
50 pub const fn is_vector(&self) -> bool {
51 matches!(self, Self::Svg)
52 }
53
54 #[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
69#[serde(rename_all = "camelCase")]
70pub struct ImageVariant {
71 pub path: String,
73
74 pub hash: DocumentId,
76
77 pub width: u32,
79
80 pub height: u32,
82
83 pub scale: f32,
85
86 pub size: u64,
88}
89
90impl ImageVariant {
91 #[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 #[must_use]
106 pub fn with_hash(mut self, hash: DocumentId) -> Self {
107 self.hash = hash;
108 self
109 }
110
111 #[must_use]
113 pub const fn with_size(mut self, size: u64) -> Self {
114 self.size = size;
115 self
116 }
117
118 #[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 #[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 #[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
139#[serde(rename_all = "camelCase")]
140pub struct ImageAsset {
141 pub id: String,
143
144 pub path: String,
146
147 pub hash: DocumentId,
149
150 pub format: ImageFormat,
152
153 pub size: u64,
155
156 #[serde(default, skip_serializing_if = "Option::is_none")]
158 pub width: Option<u32>,
159
160 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub height: Option<u32>,
163
164 #[serde(default, skip_serializing_if = "Option::is_none")]
166 pub alt: Option<String>,
167
168 #[serde(default, skip_serializing_if = "Option::is_none")]
170 pub title: Option<String>,
171
172 #[serde(default, skip_serializing_if = "Option::is_none")]
174 pub attribution: Option<String>,
175
176 #[serde(default, skip_serializing_if = "Vec::is_empty")]
181 pub variants: Vec<ImageVariant>,
182}
183
184impl ImageAsset {
185 #[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 #[must_use]
207 pub fn with_hash(mut self, hash: DocumentId) -> Self {
208 self.hash = hash;
209 self
210 }
211
212 #[must_use]
214 pub const fn with_size(mut self, size: u64) -> Self {
215 self.size = size;
216 self
217 }
218
219 #[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 #[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 #[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 #[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 #[must_use]
250 pub fn with_path(mut self, path: impl Into<String>) -> Self {
251 self.path = path.into();
252 self
253 }
254
255 #[must_use]
257 pub fn with_variant(mut self, variant: ImageVariant) -> Self {
258 self.variants.push(variant);
259 self
260 }
261
262 #[must_use]
264 pub fn with_variants(mut self, variants: Vec<ImageVariant>) -> Self {
265 self.variants = variants;
266 self
267 }
268
269 #[must_use]
271 pub fn has_variants(&self) -> bool {
272 !self.variants.is_empty()
273 }
274
275 #[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 #[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 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 self.variants.iter().max_by_key(|v| v.width)
303 } else {
304 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 let best = image.best_variant_for_width(80);
452 assert!(best.is_some());
453 assert_eq!(best.unwrap().width, 100);
454
455 let best = image.best_variant_for_width(150);
457 assert!(best.is_some());
458 assert_eq!(best.unwrap().width, 200);
459
460 let best = image.best_variant_for_width(250);
462 assert!(best.is_some());
463 assert_eq!(best.unwrap().width, 300);
464
465 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}