Skip to main content

ocre/storage/
variant.rs

1//! Image variants through Cloudflare Image Transformations: resized copies
2//! made on demand by Cloudflare's edge from a URL, never by the Worker.
3
4use std::fmt::Write as _;
5
6/// How a [`Variant`] fits the image in its `width` × `height` box (Cloudflare's `fit` option).
7///
8/// # Examples
9///
10/// ```
11/// use ocre::storage::{Fit, Variant};
12///
13/// let thumb = Variant::new().width(200).height(200).fit(Fit::Cover);
14/// assert_eq!(thumb.path("/photos/1/image"), "/cdn-cgi/image/width=200,height=200,fit=cover,format=auto/photos/1/image");
15/// ```
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum Fit {
18    /// Like `Contain`, but never enlarges a smaller image (Active Storage's `resize_to_limit`).
19    ScaleDown,
20    /// Fits inside the box, keeping the aspect ratio (`resize_to_fit`).
21    Contain,
22    /// Fills the box, cropping what overflows (`resize_to_fill`).
23    Cover,
24    /// Like `Cover`, but never enlarges a smaller image.
25    Crop,
26    /// Like `Contain`, then pads to the exact box size (`resize_and_pad`).
27    Pad,
28}
29
30impl Fit {
31    fn as_str(self) -> &'static str {
32        match self {
33            Self::ScaleDown => "scale-down",
34            Self::Contain => "contain",
35            Self::Cover => "cover",
36            Self::Crop => "crop",
37            Self::Pad => "pad",
38        }
39    }
40}
41
42/// A resized version of an image, made by Cloudflare Image Transformations (Active Storage's variants).
43///
44/// [`Variant::path`] builds `/cdn-cgi/image/<options>/<source>`: Cloudflare's
45/// edge fetches the source image (for example the app's own route that
46/// [`serve`](crate::storage::serve)s it), resizes it and caches the result.
47/// Variants are lazy (made on the first request, like Rails'
48/// `variant(...).processed` on first view) and never touch the Worker's
49/// CPU or R2's storage. `format=auto` is always set: browsers that accept
50/// AVIF or WebP get it, others get the original format.
51///
52/// Needs a custom domain (a zone on Cloudflare) with Transformations turned
53/// on in the dashboard (Images > Transformations); `*.workers.dev` hosts
54/// cannot use it. Declare variants as constants, like [`Rules`](crate::storage::Rules).
55///
56/// # Examples
57///
58/// ```
59/// use ocre::storage::{Fit, Variant};
60///
61/// const THUMB: Variant = Variant::new().width(300).height(300).fit(Fit::Cover).quality(80);
62/// const LARGE: Variant = Variant::new().width(1600);
63///
64/// assert_eq!(
65///     THUMB.path("/photos/7/image"),
66///     "/cdn-cgi/image/width=300,height=300,fit=cover,quality=80,format=auto/photos/7/image"
67/// );
68/// assert_eq!(LARGE.path("https://files.example.com/a.jpg"), "/cdn-cgi/image/width=1600,format=auto/https://files.example.com/a.jpg");
69/// ```
70#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
71pub struct Variant {
72    width: Option<u32>,
73    height: Option<u32>,
74    fit: Option<Fit>,
75    quality: Option<u8>,
76}
77
78impl Variant {
79    /// A variant with no resizing: only `format=auto`.
80    ///
81    /// # Examples
82    ///
83    /// ```
84    /// assert_eq!(ocre::storage::Variant::new().path("/logo.png"), "/cdn-cgi/image/format=auto/logo.png");
85    /// ```
86    pub const fn new() -> Self {
87        Self { width: None, height: None, fit: None, quality: None }
88    }
89
90    /// Sets the largest width, in pixels.
91    ///
92    /// # Examples
93    ///
94    /// ```
95    /// let path = ocre::storage::Variant::new().width(640).path("/a.png");
96    /// assert_eq!(path, "/cdn-cgi/image/width=640,format=auto/a.png");
97    /// ```
98    pub const fn width(mut self, pixels: u32) -> Self {
99        self.width = Some(pixels);
100        self
101    }
102
103    /// Sets the largest height, in pixels.
104    ///
105    /// # Examples
106    ///
107    /// ```
108    /// let path = ocre::storage::Variant::new().height(480).path("/a.png");
109    /// assert_eq!(path, "/cdn-cgi/image/height=480,format=auto/a.png");
110    /// ```
111    pub const fn height(mut self, pixels: u32) -> Self {
112        self.height = Some(pixels);
113        self
114    }
115
116    /// Sets how the image fits the `width` × `height` box; without it, Cloudflare scales the image down to fit.
117    ///
118    /// # Examples
119    ///
120    /// ```
121    /// use ocre::storage::{Fit, Variant};
122    ///
123    /// let path = Variant::new().width(100).height(100).fit(Fit::Pad).path("/a.png");
124    /// assert_eq!(path, "/cdn-cgi/image/width=100,height=100,fit=pad,format=auto/a.png");
125    /// ```
126    pub const fn fit(mut self, fit: Fit) -> Self {
127        self.fit = Some(fit);
128        self
129    }
130
131    /// Sets the JPEG/WebP/AVIF quality, 1 to 100 (values outside are clamped).
132    ///
133    /// # Examples
134    ///
135    /// ```
136    /// let path = ocre::storage::Variant::new().quality(200).path("/a.jpg");
137    /// assert_eq!(path, "/cdn-cgi/image/quality=100,format=auto/a.jpg");
138    /// ```
139    pub const fn quality(mut self, quality: u8) -> Self {
140        self.quality = Some(if quality == 0 {
141            1
142        } else if quality > 100 {
143            100
144        } else {
145            quality
146        });
147        self
148    }
149
150    /// The same-origin path of this variant of `source`: `/cdn-cgi/image/<options>/<source>`.
151    ///
152    /// `source` is a path on the same zone (`/photos/1/image`, the leading
153    /// `/` is dropped as Cloudflare expects) or an absolute `https://` URL
154    /// (allowed in the zone's Transformations settings). Pure; use it in
155    /// templates as `src="{{ THUMB.path(photo_path) }}"`.
156    ///
157    /// # Examples
158    ///
159    /// ```
160    /// let path = ocre::storage::Variant::new().width(64).path("//avatars/1");
161    /// assert_eq!(path, "/cdn-cgi/image/width=64,format=auto/avatars/1");
162    /// ```
163    pub fn path(&self, source: &str) -> String {
164        let mut path = String::from("/cdn-cgi/image/");
165        if let Some(width) = self.width {
166            write!(path, "width={width},").expect("writing to a String");
167        }
168        if let Some(height) = self.height {
169            write!(path, "height={height},").expect("writing to a String");
170        }
171        if let Some(fit) = self.fit {
172            write!(path, "fit={},", fit.as_str()).expect("writing to a String");
173        }
174        if let Some(quality) = self.quality {
175            write!(path, "quality={quality},").expect("writing to a String");
176        }
177        path.push_str("format=auto/");
178        let absolute = source.starts_with("https://") || source.starts_with("http://");
179        path.push_str(if absolute { source } else { source.trim_start_matches('/') });
180        path
181    }
182}
183
184#[cfg(test)]
185#[path = "../../tests/storage/variant.rs"]
186mod tests;