use serde::{Deserialize, Serialize};
use crate::DocumentId;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ImageFormat {
Avif,
WebP,
Png,
Jpeg,
Svg,
}
impl ImageFormat {
#[must_use]
pub const fn extension(&self) -> &'static str {
match self {
Self::Avif => "avif",
Self::WebP => "webp",
Self::Png => "png",
Self::Jpeg => "jpg",
Self::Svg => "svg",
}
}
#[must_use]
pub const fn mime_type(&self) -> &'static str {
match self {
Self::Avif => "image/avif",
Self::WebP => "image/webp",
Self::Png => "image/png",
Self::Jpeg => "image/jpeg",
Self::Svg => "image/svg+xml",
}
}
#[must_use]
pub const fn is_vector(&self) -> bool {
matches!(self, Self::Svg)
}
#[must_use]
pub const fn is_raster(&self) -> bool {
!self.is_vector()
}
}
impl std::fmt::Display for ImageFormat {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.extension())
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ImageVariant {
pub path: String,
pub hash: DocumentId,
pub width: u32,
pub height: u32,
pub scale: f32,
pub size: u64,
}
impl ImageVariant {
#[must_use]
pub fn new(path: impl Into<String>, width: u32, height: u32, scale: f32) -> Self {
Self {
path: path.into(),
hash: DocumentId::pending(),
width,
height,
scale,
size: 0,
}
}
#[must_use]
pub fn with_hash(mut self, hash: DocumentId) -> Self {
self.hash = hash;
self
}
#[must_use]
pub const fn with_size(mut self, size: u64) -> Self {
self.size = size;
self
}
#[must_use]
pub fn scale_1x(path: impl Into<String>, width: u32, height: u32) -> Self {
Self::new(path, width, height, 1.0)
}
#[must_use]
pub fn scale_2x(path: impl Into<String>, width: u32, height: u32) -> Self {
Self::new(path, width, height, 2.0)
}
#[must_use]
pub fn scale_3x(path: impl Into<String>, width: u32, height: u32) -> Self {
Self::new(path, width, height, 3.0)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ImageAsset {
pub id: String,
pub path: String,
pub hash: DocumentId,
pub format: ImageFormat,
pub size: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub width: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub height: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub alt: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub attribution: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub variants: Vec<ImageVariant>,
}
impl ImageAsset {
#[must_use]
pub fn new(id: impl Into<String>, format: ImageFormat) -> Self {
let id = id.into();
let path = format!("assets/images/{}.{}", id, format.extension());
Self {
id,
path,
hash: DocumentId::pending(),
format,
size: 0,
width: None,
height: None,
alt: None,
title: None,
attribution: None,
variants: Vec::new(),
}
}
#[must_use]
pub fn with_hash(mut self, hash: DocumentId) -> Self {
self.hash = hash;
self
}
#[must_use]
pub const fn with_size(mut self, size: u64) -> Self {
self.size = size;
self
}
#[must_use]
pub fn with_dimensions(mut self, width: u32, height: u32) -> Self {
self.width = Some(width);
self.height = Some(height);
self
}
#[must_use]
pub fn with_alt(mut self, alt: impl Into<String>) -> Self {
self.alt = Some(alt.into());
self
}
#[must_use]
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
#[must_use]
pub fn with_attribution(mut self, attribution: impl Into<String>) -> Self {
self.attribution = Some(attribution.into());
self
}
#[must_use]
pub fn with_path(mut self, path: impl Into<String>) -> Self {
self.path = path.into();
self
}
#[must_use]
pub fn with_variant(mut self, variant: ImageVariant) -> Self {
self.variants.push(variant);
self
}
#[must_use]
pub fn with_variants(mut self, variants: Vec<ImageVariant>) -> Self {
self.variants = variants;
self
}
#[must_use]
pub fn has_variants(&self) -> bool {
!self.variants.is_empty()
}
#[must_use]
pub fn variant_for_scale(&self, scale: f32) -> Option<&ImageVariant> {
self.variants
.iter()
.find(|v| (v.scale - scale).abs() < 0.01)
}
#[must_use]
pub fn best_variant_for_width(&self, target_width: u32) -> Option<&ImageVariant> {
if self.variants.is_empty() {
return None;
}
let mut candidates: Vec<_> = self
.variants
.iter()
.filter(|v| v.width >= target_width)
.collect();
if candidates.is_empty() {
self.variants.iter().max_by_key(|v| v.width)
} else {
candidates.sort_by_key(|v| v.width);
candidates.first().copied()
}
}
}
impl super::Asset for ImageAsset {
fn id(&self) -> &str {
&self.id
}
fn path(&self) -> &str {
&self.path
}
fn hash(&self) -> &DocumentId {
&self.hash
}
fn size(&self) -> u64 {
self.size
}
fn mime_type(&self) -> &str {
self.format.mime_type()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_image_format_extension() {
assert_eq!(ImageFormat::Avif.extension(), "avif");
assert_eq!(ImageFormat::WebP.extension(), "webp");
assert_eq!(ImageFormat::Png.extension(), "png");
assert_eq!(ImageFormat::Jpeg.extension(), "jpg");
assert_eq!(ImageFormat::Svg.extension(), "svg");
}
#[test]
fn test_image_format_mime_type() {
assert_eq!(ImageFormat::Avif.mime_type(), "image/avif");
assert_eq!(ImageFormat::Svg.mime_type(), "image/svg+xml");
}
#[test]
fn test_image_format_vector_raster() {
assert!(ImageFormat::Svg.is_vector());
assert!(!ImageFormat::Png.is_vector());
assert!(ImageFormat::Png.is_raster());
}
#[test]
fn test_image_asset_new() {
let image = ImageAsset::new("logo", ImageFormat::Png);
assert_eq!(image.id, "logo");
assert_eq!(image.path, "assets/images/logo.png");
assert_eq!(image.format, ImageFormat::Png);
}
#[test]
fn test_image_asset_builder() {
let image = ImageAsset::new("photo", ImageFormat::Jpeg)
.with_dimensions(1920, 1080)
.with_alt("A beautiful sunset")
.with_size(524_288);
assert_eq!(image.width, Some(1920));
assert_eq!(image.height, Some(1080));
assert_eq!(image.alt, Some("A beautiful sunset".to_string()));
assert_eq!(image.size, 524_288);
}
#[test]
fn test_image_asset_serialization() {
let image = ImageAsset::new("test", ImageFormat::Png)
.with_dimensions(100, 100)
.with_alt("Test image");
let json = serde_json::to_string_pretty(&image).unwrap();
assert!(json.contains(r#""id": "test""#));
assert!(json.contains(r#""format": "png""#));
assert!(json.contains(r#""width": 100"#));
let deserialized: ImageAsset = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.id, image.id);
assert_eq!(deserialized.format, image.format);
}
#[test]
fn test_image_variant_creation() {
let variant = ImageVariant::new("assets/images/logo@2x.png", 400, 200, 2.0).with_size(8192);
assert_eq!(variant.width, 400);
assert_eq!(variant.height, 200);
assert!((variant.scale - 2.0).abs() < f32::EPSILON);
assert_eq!(variant.size, 8192);
}
#[test]
fn test_image_variant_scale_helpers() {
let v1x = ImageVariant::scale_1x("logo.png", 100, 50);
let v2x = ImageVariant::scale_2x("logo@2x.png", 200, 100);
let v3x = ImageVariant::scale_3x("logo@3x.png", 300, 150);
assert!((v1x.scale - 1.0).abs() < f32::EPSILON);
assert!((v2x.scale - 2.0).abs() < f32::EPSILON);
assert!((v3x.scale - 3.0).abs() < f32::EPSILON);
}
#[test]
fn test_image_asset_with_variants() {
let image = ImageAsset::new("logo", ImageFormat::Png)
.with_dimensions(100, 50)
.with_variant(ImageVariant::scale_1x("assets/images/logo.png", 100, 50))
.with_variant(ImageVariant::scale_2x(
"assets/images/logo@2x.png",
200,
100,
));
assert!(image.has_variants());
assert_eq!(image.variants.len(), 2);
}
#[test]
fn test_image_variant_for_scale() {
let image = ImageAsset::new("logo", ImageFormat::Png)
.with_variant(ImageVariant::scale_1x("logo.png", 100, 50))
.with_variant(ImageVariant::scale_2x("logo@2x.png", 200, 100));
assert!(image.variant_for_scale(1.0).is_some());
assert!(image.variant_for_scale(2.0).is_some());
assert!(image.variant_for_scale(3.0).is_none());
}
#[test]
fn test_image_best_variant_for_width() {
let image = ImageAsset::new("logo", ImageFormat::Png)
.with_variant(ImageVariant::scale_1x("logo.png", 100, 50))
.with_variant(ImageVariant::scale_2x("logo@2x.png", 200, 100))
.with_variant(ImageVariant::scale_3x("logo@3x.png", 300, 150));
let best = image.best_variant_for_width(80);
assert!(best.is_some());
assert_eq!(best.unwrap().width, 100);
let best = image.best_variant_for_width(150);
assert!(best.is_some());
assert_eq!(best.unwrap().width, 200);
let best = image.best_variant_for_width(250);
assert!(best.is_some());
assert_eq!(best.unwrap().width, 300);
let best = image.best_variant_for_width(400);
assert!(best.is_some());
assert_eq!(best.unwrap().width, 300);
}
#[test]
fn test_image_variant_serialization() {
let image = ImageAsset::new("responsive", ImageFormat::Png)
.with_dimensions(100, 50)
.with_variant(ImageVariant::scale_2x(
"assets/images/responsive@2x.png",
200,
100,
));
let json = serde_json::to_string_pretty(&image).unwrap();
assert!(json.contains("variants"));
assert!(json.contains("@2x"));
let deserialized: ImageAsset = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.variants.len(), 1);
assert_eq!(deserialized.variants[0].width, 200);
}
}