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;