Skip to main content

otf_pixels_codec_gif/
encoder.rs

1//! The GIF encoder: single frame, with palette quantization.
2//!
3//! SPEC §Formats fixes the scope: "encode is single-frame + palette
4//! quantization. Animation pipelines are v2." An encoder that accepted frames
5//! would need a frame model in the engine, which is the thing v2 is for.
6//!
7//! # Memory
8//!
9//! Encoding is **internally buffered**. Quantization needs a histogram of the
10//! whole image before it can choose a palette, and dithering carries error
11//! forward between rows, so there is no prefix of the image that can be
12//! encoded before the rest has been seen. That is ADR-0005's allowance for
13//! formats that leave no choice, and it is a property of palette quantization
14//! rather than of this implementation.
15
16use otf_pixels_compress::LzwEncoder;
17use otf_pixels_core::{
18    EncodeOptions, Encoder, ImageDescriptor, PixelFormat, PixelsError, Result, Sink,
19};
20
21use crate::format::{SIGNATURE_89A, label, write_sub_blocks};
22use otf_pixels_compress::{build_palette, quantize_with};
23
24/// Encodes a single-frame GIF.
25#[derive(Debug)]
26pub struct GifEncoder {
27    /// How much quantization error is diffused, 0..=1.
28    dither: f32,
29    /// Palette entries to aim for, at most 256.
30    colours: usize,
31    state: Option<State>,
32}
33
34/// Everything fixed once the descriptor is known.
35#[derive(Debug)]
36struct State {
37    descriptor: ImageDescriptor,
38    /// The image as RGB8, accumulated until `finish`.
39    rgb: Vec<u8>,
40    rows_written: u32,
41}
42
43impl Default for GifEncoder {
44    fn default() -> Self {
45        Self::new()
46    }
47}
48
49impl GifEncoder {
50    /// An encoder with a full 256-colour palette and dithering.
51    #[must_use]
52    pub const fn new() -> Self {
53        Self {
54            dither: 1.0,
55            colours: 256,
56            state: None,
57        }
58    }
59
60    /// An encoder aiming for at most `colours` palette entries.
61    ///
62    /// # Errors
63    ///
64    /// Returns [`PixelsError::InvalidArgument`] outside 2..=256.
65    pub fn with_colours(mut self, colours: usize) -> Result<Self> {
66        if !(2..=256).contains(&colours) {
67            return Err(PixelsError::invalid_argument(
68                "colours",
69                format!("palette size must be in 2..=256, got {colours}"),
70            ));
71        }
72        self.colours = colours;
73        Ok(self)
74    }
75
76    /// An encoder with dithering disabled.
77    #[must_use]
78    pub const fn without_dithering(mut self) -> Self {
79        self.dither = 0.0;
80        self
81    }
82
83    /// An encoder diffusing `dither` of the quantization error, 0 (none) to
84    /// 1 (full Floyd-Steinberg, the default), as sharp's `dither`.
85    ///
86    /// # Errors
87    ///
88    /// Returns [`PixelsError::InvalidArgument`] outside 0..=1.
89    pub fn with_dither(mut self, dither: f32) -> Result<Self> {
90        if !(0.0..=1.0).contains(&dither) {
91            return Err(PixelsError::invalid_argument(
92                "dither",
93                format!("must be in 0..=1, got {dither}"),
94            ));
95        }
96        self.dither = dither;
97        Ok(self)
98    }
99
100    /// An encoder configured from generic encode options.
101    ///
102    /// GIF has no quality dial in the JPEG sense — it is lossless *given a
103    /// palette*, and the palette is where all the loss lives. So quality is
104    /// read as palette size, mapping 1..=100 onto 2..=256 entries, which is
105    /// the axis that actually trades size for fidelity, unless
106    /// [`EncodeOptions::colours`] gives the size outright.
107    /// [`EncodeOptions::dither`] sets the dithering.
108    #[must_use]
109    pub fn from_options(options: &EncodeOptions) -> Self {
110        let colours = options.colours.map_or_else(
111            || {
112                let quality = u32::from(options.quality.clamp(1, 100));
113                (2 + (quality - 1) * 254 / 99) as usize
114            },
115            |colours| usize::from(colours).clamp(2, 256),
116        );
117        Self {
118            dither: options.dither.unwrap_or(1.0).clamp(0.0, 1.0),
119            colours,
120            state: None,
121        }
122    }
123
124    /// The palette size this encoder aims for.
125    #[must_use]
126    pub const fn colours(&self) -> usize {
127        self.colours
128    }
129}
130
131/// Convert one row of `format` into RGB8, appending to `out`.
132///
133/// Alpha is composited against black rather than dropped. GIF's only
134/// transparency is a palette index, so a translucent pixel has to become
135/// *something*; blending against black is what viewers show for a flattened
136/// image and is the least surprising of the available wrong answers.
137fn append_as_rgb(row: &[u8], format: PixelFormat, out: &mut Vec<u8>) -> Result<()> {
138    let channels = format.channels();
139    match format {
140        PixelFormat::Rgb8 => out.extend_from_slice(row),
141        PixelFormat::Rgba8 => {
142            for pixel in row.chunks_exact(channels) {
143                let alpha = u32::from(pixel.get(3).copied().unwrap_or(255));
144                for channel in 0..3 {
145                    let value = u32::from(pixel.get(channel).copied().unwrap_or(0));
146                    out.push(((value * alpha + 127) / 255) as u8);
147                }
148            }
149        }
150        PixelFormat::Gray8 => {
151            for &value in row {
152                out.extend_from_slice(&[value, value, value]);
153            }
154        }
155        PixelFormat::GrayA8 => {
156            for pixel in row.chunks_exact(2) {
157                let alpha = u32::from(pixel.get(1).copied().unwrap_or(255));
158                let value = u32::from(pixel.first().copied().unwrap_or(0));
159                let blended = ((value * alpha + 127) / 255) as u8;
160                out.extend_from_slice(&[blended, blended, blended]);
161            }
162        }
163        other => {
164            return Err(PixelsError::unsupported(format!(
165                "GIF encoding needs an 8-bit format; got {other}. Convert first."
166            )));
167        }
168    }
169    Ok(())
170}
171
172impl Encoder for GifEncoder {
173    fn write_header(&mut self, desc: &ImageDescriptor, _sink: &mut dyn Sink) -> Result<()> {
174        if self.state.is_some() {
175            return Err(PixelsError::invalid_argument(
176                "descriptor",
177                "write_header called more than once",
178            ));
179        }
180        // GIF dimensions are 16-bit; a larger image cannot be represented at
181        // all, so this is a format limit rather than a policy.
182        if desc.width > u32::from(u16::MAX) || desc.height > u32::from(u16::MAX) {
183            return Err(PixelsError::unsupported(format!(
184                "GIF dimensions are 16-bit; {}x{} does not fit",
185                desc.width, desc.height
186            )));
187        }
188        // Reject the format here rather than on the first row, so a caller
189        // learns before streaming an image.
190        let mut probe = Vec::new();
191        append_as_rgb(&[], desc.pixel, &mut probe)?;
192
193        // Nothing is written yet: the palette is not known until every pixel
194        // has been seen, and the palette is part of the header.
195        self.state = Some(State {
196            descriptor: *desc,
197            rgb: Vec::with_capacity(desc.width as usize * desc.height as usize * 3),
198            rows_written: 0,
199        });
200        Ok(())
201    }
202
203    fn write_row(&mut self, row: &[u8], _sink: &mut dyn Sink) -> Result<()> {
204        let Some(state) = self.state.as_mut() else {
205            return Err(PixelsError::invalid_argument(
206                "row",
207                "write_row called before write_header",
208            ));
209        };
210        let expected = state.descriptor.row_bytes();
211        if row.len() != expected {
212            return Err(PixelsError::invalid_argument(
213                "row",
214                format!("row is {} bytes, expected {expected}", row.len()),
215            ));
216        }
217        if state.rows_written >= state.descriptor.height {
218            return Err(PixelsError::invalid_argument(
219                "row",
220                format!("more than {} rows written", state.descriptor.height),
221            ));
222        }
223        append_as_rgb(row, state.descriptor.pixel, &mut state.rgb)?;
224        state.rows_written += 1;
225        Ok(())
226    }
227
228    fn finish(&mut self, sink: &mut dyn Sink) -> Result<()> {
229        let Some(state) = self.state.as_mut() else {
230            return Err(PixelsError::malformed(
231                "gif",
232                "finish called before write_header",
233            ));
234        };
235        if state.rows_written != state.descriptor.height {
236            return Err(PixelsError::malformed(
237                "gif",
238                format!(
239                    "{} of {} rows written; a partial image is never emitted",
240                    state.rows_written, state.descriptor.height
241                ),
242            ));
243        }
244
245        let width = state.descriptor.width;
246        let height = state.descriptor.height;
247        let palette = build_palette(&state.rgb, self.colours);
248        let indices = quantize_with(&state.rgb, width as usize, &palette, self.dither);
249        let table = palette.padded();
250        let bits = palette.code_bits();
251
252        let mut out: Vec<u8> = Vec::new();
253        out.extend_from_slice(&SIGNATURE_89A);
254        out.extend_from_slice(&(width as u16).to_le_bytes());
255        out.extend_from_slice(&(height as u16).to_le_bytes());
256        // Global table present, colour resolution 8 bits, table size 2^(n+1).
257        out.push(0x80 | 0x70 | ((bits - 1) as u8));
258        out.push(0); // background index
259        out.push(0); // pixel aspect ratio: unspecified
260        for entry in &table {
261            out.extend_from_slice(entry);
262        }
263
264        // Image descriptor: one frame covering the whole canvas, no local
265        // table, not interlaced.
266        out.push(label::IMAGE);
267        out.extend_from_slice(&0_u16.to_le_bytes());
268        out.extend_from_slice(&0_u16.to_le_bytes());
269        out.extend_from_slice(&(width as u16).to_le_bytes());
270        out.extend_from_slice(&(height as u16).to_le_bytes());
271        out.push(0);
272
273        // LZW's minimum code width must be at least 2, even for a 2-colour
274        // image: a width of 1 leaves no room for both the clear and end codes.
275        let minimum_width = bits.max(2);
276        out.push(minimum_width as u8);
277        let compressed = LzwEncoder::gif(minimum_width)
278            .map_err(crate::compress_error)?
279            .encode(&indices);
280        write_sub_blocks(&mut out, &compressed);
281        out.push(label::TRAILER);
282
283        sink.write_all(&out)?;
284        sink.flush()?;
285
286        // Release the raster now rather than at drop.
287        state.rgb = Vec::new();
288        Ok(())
289    }
290}
291
292#[cfg(test)]
293#[allow(
294    clippy::unwrap_used,
295    clippy::expect_used,
296    clippy::indexing_slicing,
297    clippy::panic,
298    reason = "tests operate on known-good values and assert shapes directly"
299)]
300mod tests {
301    use super::*;
302    use crate::decoder::GifDecoder;
303    use otf_pixels_core::{Decoder, Limits};
304
305    fn encode(desc: &ImageDescriptor, raster: &[u8], encoder: GifEncoder) -> Result<Vec<u8>> {
306        let mut encoder = encoder;
307        let mut out: Vec<u8> = Vec::new();
308        encoder.write_header(desc, &mut out)?;
309        for row in raster.chunks_exact(desc.row_bytes()) {
310            encoder.write_row(row, &mut out)?;
311        }
312        encoder.finish(&mut out)?;
313        Ok(out)
314    }
315
316    fn decode(bytes: &[u8]) -> Result<(ImageDescriptor, Vec<u8>)> {
317        let mut decoder = GifDecoder::new(bytes, Limits::default())?;
318        let descriptor = decoder.descriptor();
319        let mut raster = Vec::new();
320        let mut row = vec![0_u8; descriptor.row_bytes()];
321        for _ in 0..descriptor.height {
322            decoder.read_row(&mut row)?;
323            raster.extend_from_slice(&row);
324        }
325        Ok((descriptor, raster))
326    }
327
328    /// An image using at most `colours` distinct values, so it can round-trip
329    /// through a palette exactly.
330    fn flat_art(width: u32, height: u32, colours: usize) -> (ImageDescriptor, Vec<u8>) {
331        let descriptor = ImageDescriptor::new(width, height, PixelFormat::Rgb8).unwrap();
332        let mut bytes = Vec::new();
333        for i in 0..(width * height) as usize {
334            // Spread `colours` distinct values across the full range without
335            // leaving u8, so the fixture stays legal for any palette size.
336            let n = (i % colours) as u32;
337            let spread = (n * 255 / colours.max(1) as u32) as u8;
338            bytes.extend_from_slice(&[spread, 255 - spread, spread / 2]);
339        }
340        (descriptor, bytes)
341    }
342
343    #[test]
344    fn flat_colour_art_round_trips_exactly() {
345        // The case GIF is still genuinely used for. Fewer distinct colours
346        // than the palette holds means the encoding must be lossless.
347        for colours in [2_usize, 5, 16, 200] {
348            let (descriptor, raster) = flat_art(23, 17, colours);
349            let bytes = encode(&descriptor, &raster, GifEncoder::new())
350                .unwrap_or_else(|e| panic!("{colours} colours: {e}"));
351            let (out_desc, decoded) = decode(&bytes).unwrap();
352            assert_eq!((out_desc.width, out_desc.height), (23, 17));
353            assert_eq!(out_desc.pixel, PixelFormat::Rgba8);
354
355            for (index, (want, got)) in raster
356                .chunks_exact(3)
357                .zip(decoded.chunks_exact(4))
358                .enumerate()
359            {
360                assert_eq!(&got[..3], want, "{colours} colours: pixel {index} changed");
361                assert_eq!(got[3], 255, "pixel {index} lost opacity");
362            }
363        }
364    }
365
366    #[test]
367    fn the_output_starts_with_a_signature_and_ends_with_a_trailer() {
368        let (descriptor, raster) = flat_art(4, 4, 4);
369        let bytes = encode(&descriptor, &raster, GifEncoder::new()).unwrap();
370        assert_eq!(&bytes[..6], b"GIF89a");
371        assert_eq!(bytes.last().copied(), Some(label::TRAILER));
372    }
373
374    #[test]
375    fn a_photographic_image_round_trips_approximately() {
376        // 256 colours cannot represent a gradient exactly, so the check is
377        // that it is close rather than equal.
378        let descriptor = ImageDescriptor::new(64, 64, PixelFormat::Rgb8).unwrap();
379        let mut raster = Vec::new();
380        for y in 0..64_u32 {
381            for x in 0..64_u32 {
382                raster.extend_from_slice(&[(x * 4) as u8, (y * 4) as u8, ((x + y) * 2) as u8]);
383            }
384        }
385        let bytes = encode(&descriptor, &raster, GifEncoder::new()).unwrap();
386        let (_, decoded) = decode(&bytes).unwrap();
387
388        let mut total = 0_u64;
389        for (want, got) in raster.chunks_exact(3).zip(decoded.chunks_exact(4)) {
390            for channel in 0..3 {
391                total += u64::from(want[channel].abs_diff(got[channel]));
392            }
393        }
394        let mean = total as f64 / (raster.len() as f64);
395        assert!(mean < 8.0, "mean per-channel error was {mean}");
396    }
397
398    #[test]
399    fn every_input_format_encodes() {
400        for format in [
401            PixelFormat::Gray8,
402            PixelFormat::GrayA8,
403            PixelFormat::Rgb8,
404            PixelFormat::Rgba8,
405        ] {
406            let descriptor = ImageDescriptor::new(8, 8, format).unwrap();
407            let raster = vec![128_u8; descriptor.byte_len().unwrap()];
408            let bytes = encode(&descriptor, &raster, GifEncoder::new())
409                .unwrap_or_else(|e| panic!("{format}: {e}"));
410            let (out_desc, _) = decode(&bytes).unwrap();
411            assert_eq!((out_desc.width, out_desc.height), (8, 8), "{format}");
412        }
413    }
414
415    #[test]
416    fn a_wide_format_is_unsupported_not_silently_narrowed() {
417        let descriptor = ImageDescriptor::new(4, 4, PixelFormat::Rgb16).unwrap();
418        let mut encoder = GifEncoder::new();
419        let mut out = Vec::new();
420        let error = encoder.write_header(&descriptor, &mut out).unwrap_err();
421        assert!(error.to_string().contains("8-bit"), "{error}");
422        assert!(
423            out.is_empty(),
424            "nothing should be written for a rejected format"
425        );
426    }
427
428    #[test]
429    fn a_short_image_is_an_error_not_a_truncated_gif() {
430        let (descriptor, _) = flat_art(4, 4, 4);
431        let mut encoder = GifEncoder::new();
432        let mut out = Vec::new();
433        encoder.write_header(&descriptor, &mut out).unwrap();
434        encoder.write_row(&[0; 12], &mut out).unwrap();
435        assert!(encoder.finish(&mut out).is_err());
436    }
437
438    #[test]
439    fn extra_rows_and_wrong_lengths_are_errors() {
440        let descriptor = ImageDescriptor::new(4, 1, PixelFormat::Rgb8).unwrap();
441        let mut encoder = GifEncoder::new();
442        let mut out = Vec::new();
443        encoder.write_header(&descriptor, &mut out).unwrap();
444        assert!(encoder.write_row(&[0; 11], &mut out).is_err(), "short row");
445        assert!(encoder.write_row(&[0; 13], &mut out).is_err(), "long row");
446        encoder.write_row(&[0; 12], &mut out).unwrap();
447        assert!(encoder.write_row(&[0; 12], &mut out).is_err(), "extra row");
448    }
449
450    #[test]
451    fn a_palette_size_outside_the_legal_range_is_rejected() {
452        assert!(GifEncoder::new().with_colours(1).is_err());
453        assert!(GifEncoder::new().with_colours(257).is_err());
454        assert!(GifEncoder::new().with_colours(2).is_ok());
455        assert!(GifEncoder::new().with_colours(256).is_ok());
456    }
457
458    #[test]
459    fn options_map_quality_onto_palette_size() {
460        assert_eq!(
461            GifEncoder::from_options(&EncodeOptions::default()).colours(),
462            204
463        );
464        let lowest = EncodeOptions::with_quality(1).unwrap();
465        assert_eq!(GifEncoder::from_options(&lowest).colours(), 2);
466        let highest = EncodeOptions::with_quality(100).unwrap();
467        assert_eq!(GifEncoder::from_options(&highest).colours(), 256);
468    }
469
470    #[test]
471    fn a_two_colour_image_uses_a_legal_code_width() {
472        // LZW needs a minimum code width of 2 even for a 1-bit palette,
473        // because width 1 has no room for both the clear and end codes. This
474        // is the smallest image that exercises that clamp.
475        let (descriptor, raster) = flat_art(3, 3, 2);
476        let bytes = encode(
477            &descriptor,
478            &raster,
479            GifEncoder::new().with_colours(2).unwrap(),
480        )
481        .unwrap();
482        let (_, decoded) = decode(&bytes).unwrap();
483        assert_eq!(decoded.len(), 3 * 3 * 4);
484    }
485
486    #[test]
487    fn encoding_is_deterministic() {
488        // SPEC §Guarantees 2, and the reason quantization sorts its histogram.
489        let descriptor = ImageDescriptor::new(40, 30, PixelFormat::Rgb8).unwrap();
490        let raster: Vec<u8> = (0..descriptor.byte_len().unwrap())
491            .map(|i| ((i * 31) % 251) as u8)
492            .collect();
493        let first = encode(&descriptor, &raster, GifEncoder::new()).unwrap();
494        for _ in 0..4 {
495            let again = encode(&descriptor, &raster, GifEncoder::new()).unwrap();
496            assert_eq!(again, first, "GIF encoding is not deterministic");
497        }
498    }
499
500    #[test]
501    fn a_single_pixel_image_round_trips() {
502        let descriptor = ImageDescriptor::new(1, 1, PixelFormat::Rgb8).unwrap();
503        let raster = vec![9_u8, 8, 7];
504        let bytes = encode(&descriptor, &raster, GifEncoder::new()).unwrap();
505        let (_, decoded) = decode(&bytes).unwrap();
506        assert_eq!(&decoded[..3], [9, 8, 7]);
507    }
508
509    #[test]
510    fn options_set_the_palette_size_and_the_dithering() {
511        let descriptor = ImageDescriptor::new(40, 30, PixelFormat::Rgb8).unwrap();
512        let raster: Vec<u8> = (0..40 * 30 * 3).map(|i| (i * 7 % 256) as u8).collect();
513        let options = EncodeOptions::default().with_colours(8).unwrap();
514        let bytes = encode(&descriptor, &raster, GifEncoder::from_options(&options)).unwrap();
515        let (_, decoded) = decode(&bytes).unwrap();
516        let mut distinct: Vec<&[u8]> = decoded.chunks_exact(4).collect();
517        distinct.sort_unstable();
518        distinct.dedup();
519        assert!(distinct.len() <= 8, "{} colours", distinct.len());
520
521        // Dithering only matters when the palette cannot hold every colour.
522        let undithered = options.with_dither(0.0).unwrap();
523        assert_eq!(
524            encode(&descriptor, &raster, GifEncoder::from_options(&undithered)).unwrap(),
525            encode(
526                &descriptor,
527                &raster,
528                GifEncoder::from_options(&options).without_dithering()
529            )
530            .unwrap()
531        );
532        let half = options.with_dither(0.5).unwrap();
533        assert_ne!(
534            encode(&descriptor, &raster, GifEncoder::from_options(&half)).unwrap(),
535            encode(&descriptor, &raster, GifEncoder::from_options(&undithered)).unwrap()
536        );
537        assert!(GifEncoder::new().with_dither(2.0).is_err());
538    }
539}