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