Skip to main content

truss/codecs/
mod.rs

1//! Backend codec implementations.
2
3use crate::core::{MediaType, TransformError, TransformRequest, TransformResult};
4
5/// Raster image decoding and encoding support.
6pub mod raster;
7
8/// SVG sanitization and rasterization support.
9#[cfg(feature = "svg")]
10pub mod svg;
11
12/// Dispatches a transform request to the appropriate codec based on the input media type.
13///
14/// This is the entry point for every image transformation, and the only one: it routes an
15/// SVG input to the SVG codec and everything else to the raster codec, and rejects an
16/// unsupported conversion such as raster-to-SVG output with a clear error. The two codecs
17/// are not exported, because choosing between them is what this function does and naming
18/// the wrong one for an input is only a way to get an error.
19///
20/// GIF is an input-only format here. A single-frame GIF decodes and transforms like any
21/// other raster input; an animated one is refused rather than silently reduced to its
22/// first frame, because a caller that sends an animation and receives a still image back
23/// with exit code 0 has no way to notice the animation was discarded. `truss inspect`
24/// still reads animated GIFs and reports `isAnimated`, so a caller can branch on that
25/// before converting.
26///
27/// # Errors
28///
29/// Returns [`TransformError::UnsupportedOutputMediaType`] if a raster input requests
30/// SVG or GIF output, [`TransformError::UnsupportedInputMediaType`] for an animated GIF
31/// input, [`TransformError::CapabilityMissing`] if an SVG input is provided but the `svg`
32/// feature is not enabled, or any error propagated from the underlying codec.
33/// # Examples
34///
35/// ```
36/// use image::codecs::png::PngEncoder;
37/// use image::{ColorType, ImageEncoder, Rgba, RgbaImage};
38/// use truss::{sniff_artifact, transform, MediaType, RawArtifact, TransformOptions, TransformRequest};
39///
40/// let image = RgbaImage::from_pixel(2, 2, Rgba([10, 20, 30, 255]));
41/// let mut bytes = Vec::new();
42/// PngEncoder::new(&mut bytes)
43///     .write_image(&image, 2, 2, ColorType::Rgba8.into())
44///     .unwrap();
45///
46/// let input = sniff_artifact(RawArtifact::new(bytes, Some(MediaType::Png))).unwrap();
47/// let mut options = TransformOptions::default();
48/// options.format = Some(MediaType::Jpeg);
49/// let output = transform(TransformRequest::new(
50///     input,
51///     options,
52/// ))
53/// .unwrap();
54///
55/// assert_eq!(output.artifact.media_type, MediaType::Jpeg);
56/// assert_eq!(output.artifact.metadata.width, Some(2));
57/// assert_eq!(output.artifact.metadata.height, Some(2));
58/// ```
59///
60/// ```
61/// use image::codecs::png::PngEncoder;
62/// use image::{ColorType, ImageEncoder, Rgba, RgbaImage};
63/// use truss::{sniff_artifact, transform, MediaType, RawArtifact, TransformOptions, TransformRequest};
64///
65/// let image = RgbaImage::from_pixel(2, 2, Rgba([10, 20, 30, 255]));
66/// let mut bytes = Vec::new();
67/// PngEncoder::new(&mut bytes)
68///     .write_image(&image, 2, 2, ColorType::Rgba8.into())
69///     .unwrap();
70///
71/// let input = sniff_artifact(RawArtifact::new(bytes, Some(MediaType::Png))).unwrap();
72/// let mut options = TransformOptions::default();
73/// options.format = Some(MediaType::Avif);
74/// options.quality = Some(70);
75/// let output = transform(TransformRequest::new(
76///     input,
77///     options,
78/// ))
79/// .unwrap();
80/// let sniffed = sniff_artifact(RawArtifact::new(output.artifact.bytes.clone(), None)).unwrap();
81///
82/// assert_eq!(output.artifact.media_type, MediaType::Avif);
83/// assert_eq!(sniffed.media_type, MediaType::Avif);
84/// ```
85///
86/// ```
87/// use image::codecs::jpeg::JpegDecoder;
88/// use image::codecs::jpeg::JpegEncoder;
89/// use image::metadata::Orientation;
90/// use image::{ColorType, ImageDecoder, ImageEncoder, Rgb, RgbImage};
91/// use std::io::Cursor;
92/// use truss::{sniff_artifact, transform, MediaType, RawArtifact, TransformOptions, TransformRequest};
93///
94/// let image = RgbImage::from_pixel(2, 1, Rgb([10, 20, 30]));
95/// let exif = vec![
96///     0x49, 0x49, 0x2A, 0x00, 0x08, 0x00, 0x00, 0x00,
97///     0x01, 0x00, 0x12, 0x01, 0x03, 0x00, 0x01, 0x00,
98///     0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00,
99///     0x00, 0x00,
100/// ];
101/// let mut bytes = Vec::new();
102/// let mut encoder = JpegEncoder::new_with_quality(&mut bytes, 80);
103/// encoder.set_exif_metadata(exif).unwrap();
104/// encoder
105///     .write_image(&image, 2, 1, ColorType::Rgb8.into())
106///     .unwrap();
107///
108/// let input = sniff_artifact(RawArtifact::new(bytes, Some(MediaType::Jpeg))).unwrap();
109/// let mut options = TransformOptions::default();
110/// options.format = Some(MediaType::Jpeg);
111/// options.strip_metadata = false;
112/// options.preserve_exif = true;
113/// let output = transform(TransformRequest::new(
114///     input,
115///     options,
116/// ))
117/// .unwrap();
118///
119/// let mut decoder = JpegDecoder::new(Cursor::new(&output.artifact.bytes)).unwrap();
120/// let exif = decoder.exif_metadata().unwrap().unwrap();
121///
122/// assert_eq!(output.artifact.metadata.width, Some(1));
123/// assert_eq!(output.artifact.metadata.height, Some(2));
124/// assert_eq!(Orientation::from_exif_chunk(&exif), Some(Orientation::NoTransforms));
125/// ```
126///
127/// ```
128/// use image::codecs::jpeg::JpegDecoder;
129/// use image::codecs::jpeg::JpegEncoder;
130/// use image::{ColorType, ImageDecoder, ImageEncoder, Rgb, RgbImage};
131/// use std::io::Cursor;
132/// use truss::{sniff_artifact, transform, MediaType, RawArtifact, TransformOptions, TransformRequest};
133///
134/// let image = RgbImage::from_pixel(2, 1, Rgb([10, 20, 30]));
135/// let mut bytes = Vec::new();
136/// let mut encoder = JpegEncoder::new_with_quality(&mut bytes, 80);
137/// encoder.set_icc_profile(b"demo-icc-profile".to_vec()).unwrap();
138/// encoder
139///     .write_image(&image, 2, 1, ColorType::Rgb8.into())
140///     .unwrap();
141///
142/// let input = sniff_artifact(RawArtifact::new(bytes, Some(MediaType::Jpeg))).unwrap();
143/// let mut options = TransformOptions::default();
144/// options.format = Some(MediaType::Jpeg);
145/// options.strip_metadata = false;
146/// let output = transform(TransformRequest::new(
147///     input,
148///     options,
149/// ))
150/// .unwrap();
151///
152/// let mut decoder = JpegDecoder::new(Cursor::new(&output.artifact.bytes)).unwrap();
153/// assert_eq!(decoder.icc_profile().unwrap(), Some(b"demo-icc-profile".to_vec()));
154/// ```
155///
156/// ```
157/// use truss::{sniff_artifact, RawArtifact, TransformRequest, TransformOptions, MediaType};
158/// use truss::transform;
159///
160/// let svg_bytes = b"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"10\" height=\"10\"><rect width=\"10\" height=\"10\" fill=\"red\"/></svg>";
161/// let input = sniff_artifact(RawArtifact::new(svg_bytes.to_vec(), None)).unwrap();
162/// let mut options = TransformOptions::default();
163/// options.format = Some(MediaType::Png);
164/// options.width = Some(10);
165/// options.height = Some(10);
166/// let result = transform(TransformRequest::new(input, options)).unwrap();
167/// assert_eq!(result.artifact.media_type, MediaType::Png);
168/// ```
169#[must_use = "this function returns the transform result without side effects"]
170pub fn transform(request: TransformRequest) -> Result<TransformResult, TransformError> {
171    if request.input.media_type == MediaType::Svg {
172        #[cfg(feature = "svg")]
173        {
174            return svg::transform_svg(request);
175        }
176        #[cfg(not(feature = "svg"))]
177        {
178            let _ = request;
179            return Err(TransformError::CapabilityMissing(
180                "SVG processing is not enabled in this build".to_string(),
181            ));
182        }
183    }
184
185    if request.options.format == Some(MediaType::Svg) {
186        return Err(TransformError::UnsupportedOutputMediaType(MediaType::Svg));
187    }
188
189    if request.options.format == Some(MediaType::Gif) {
190        return Err(TransformError::UnsupportedOutputMediaType(MediaType::Gif));
191    }
192
193    // The rule is about frames, not about one container. Gating it on GIF let an animated
194    // WebP, an APNG, and an animated AVIF through, where the decoder kept the first frame
195    // and the caller was told nothing.
196    if request.input.metadata.frame_count > 1 {
197        return Err(TransformError::UnsupportedInputMediaType(format!(
198            "animated {} is not supported ({} frames); truss transforms single-frame images only",
199            request.input.media_type.as_name(),
200            request.input.metadata.frame_count
201        )));
202    }
203
204    raster::transform_raster(request)
205}
206
207#[cfg(test)]
208mod tests {
209    use super::transform;
210    use crate::core::{
211        Artifact, ArtifactMetadata, MediaType, TransformError, TransformOptions, TransformRequest,
212    };
213
214    fn animated_artifact(media_type: MediaType, signature: &[u8], frame_count: u32) -> Artifact {
215        Artifact::new(
216            signature.to_vec(),
217            media_type,
218            ArtifactMetadata {
219                width: Some(4),
220                height: Some(4),
221                frame_count,
222                duration: None,
223                has_alpha: Some(false),
224                orientation: None,
225            },
226        )
227    }
228
229    /// The rule the GIF message states is about frames, not about GIF. A picture that has
230    /// more than one of them is refused whatever container it arrived in, or the container
231    /// decides whether the caller is told their animation was reduced to a still.
232    #[test]
233    fn a_multi_frame_input_is_refused_whatever_its_container() {
234        let cases: &[(MediaType, &[u8])] = &[
235            (MediaType::Gif, b"GIF89a"),
236            (MediaType::Png, b"\x89PNG\r\n\x1a\n"),
237            (MediaType::Webp, b"RIFF\0\0\0\0WEBP"),
238            (MediaType::Avif, b"\0\0\0\x18ftypavis"),
239        ];
240
241        for &(media_type, signature) in cases {
242            let error = transform(TransformRequest::new(
243                animated_artifact(media_type, signature, 4),
244                TransformOptions {
245                    format: Some(MediaType::Png),
246                    ..TransformOptions::default()
247                },
248            ))
249            .expect_err("a multi-frame input should be refused, not reduced to one frame");
250
251            assert!(
252                matches!(error, TransformError::UnsupportedInputMediaType(ref message)
253                    if message.contains("4 frames")),
254                "{media_type:?} was not refused for having frames: {error}"
255            );
256        }
257    }
258
259    fn gif_artifact(frame_count: u32) -> Artifact {
260        // The dispatcher decides on the media type and metadata alone, before any decode,
261        // so the bytes only need the signature that put this artifact on the GIF path.
262        Artifact::new(
263            b"GIF89a".to_vec(),
264            MediaType::Gif,
265            ArtifactMetadata {
266                width: Some(4),
267                height: Some(4),
268                frame_count,
269                duration: None,
270                has_alpha: Some(false),
271                orientation: None,
272            },
273        )
274    }
275
276    #[test]
277    fn transform_rejects_an_animated_gif_input() {
278        let error = transform(TransformRequest::new(
279            gif_artifact(12),
280            TransformOptions {
281                format: Some(MediaType::Png),
282                ..TransformOptions::default()
283            },
284        ))
285        .expect_err("an animated gif should be refused, not reduced to one frame");
286
287        match error {
288            TransformError::UnsupportedInputMediaType(message) => {
289                assert!(
290                    message.contains("animated gif") && message.contains("12 frames"),
291                    "the error should name the format and the frame count, got: {message}"
292                );
293            }
294            other => panic!("expected UnsupportedInputMediaType, got: {other}"),
295        }
296    }
297
298    #[test]
299    fn transform_rejects_gif_output() {
300        let error = transform(TransformRequest::new(
301            Artifact::new(
302                b"\x89PNG\r\n\x1a\n".to_vec(),
303                MediaType::Png,
304                ArtifactMetadata::default(),
305            ),
306            TransformOptions {
307                format: Some(MediaType::Gif),
308                ..TransformOptions::default()
309            },
310        ))
311        .expect_err("gif output should be refused");
312
313        assert!(
314            matches!(
315                error,
316                TransformError::UnsupportedOutputMediaType(MediaType::Gif)
317            ),
318            "expected UnsupportedOutputMediaType(Gif), got: {error}"
319        );
320    }
321
322    #[test]
323    fn transform_accepts_a_single_frame_gif_past_the_animation_guard() {
324        // The guard must not fire for a still image. The bytes here are not a decodable
325        // GIF, so the request is expected to fail later, in the decoder, not at dispatch.
326        let error = transform(TransformRequest::new(
327            gif_artifact(1),
328            TransformOptions {
329                format: Some(MediaType::Png),
330                ..TransformOptions::default()
331            },
332        ))
333        .expect_err("truncated gif bytes cannot decode");
334
335        assert!(
336            matches!(error, TransformError::DecodeFailed(_)),
337            "a single-frame gif should reach the decoder, got: {error}"
338        );
339    }
340}