Skip to main content

otf_pixels_codec_png/
encoder.rs

1//! The PNG encoder.
2//!
3//! # Memory
4//!
5//! Encoding is **internally buffered**: DEFLATE needs the whole scanline
6//! stream before it can emit its Huffman-coded blocks, so filtered rows
7//! accumulate until [`Encoder::finish`]. That is ADR-0005's stated allowance
8//! for codecs that cannot work incrementally, and it is symmetric with the
9//! decoder — see the module docs there for why an incremental inflate and
10//! deflate are deferred rather than absent.
11//!
12//! # What is written
13//!
14//! Non-interlaced, `PLTE`-free PNG: `IHDR`, one `IDAT`, `IEND`. Adam7 and
15//! palette output would both cost bytes rather than save them for the images
16//! a pipeline produces, and every decoder must accept the plain form.
17
18use otf_pixels_core::{
19    EncodeOptions, Encoder, ImageDescriptor, PixelFormat, PixelsError, Result, Sink,
20};
21
22use crate::format::{ColorType, Filter, SIGNATURE, apply_filter, write_chunk};
23use otf_pixels_compress::{Level, zlib_compress};
24
25/// Encodes a PNG stream.
26#[derive(Debug)]
27pub struct PngEncoder {
28    level: Level,
29    /// Set by `write_header`; its presence means the header was written.
30    state: Option<State>,
31    /// The ICC profile to write as `iCCP`, if any.
32    icc: Option<Vec<u8>>,
33}
34
35/// Everything fixed once the descriptor is known.
36#[derive(Debug)]
37struct State {
38    descriptor: ImageDescriptor,
39    bit_depth: u8,
40    /// Bytes per pixel, rounded up — the filter's left-neighbour offset.
41    stride: usize,
42    /// Filtered scanlines, each prefixed by its filter byte.
43    filtered: Vec<u8>,
44    /// The previous *unfiltered* row, which filters predict from.
45    previous: Vec<u8>,
46    /// Reusable big-endian staging buffer; empty unless `bit_depth` is 16.
47    swapped: Vec<u8>,
48    rows_written: u32,
49}
50
51impl PngEncoder {
52    /// An encoder at the default compression level.
53    #[must_use]
54    pub const fn new() -> Self {
55        Self {
56            level: Level::DEFAULT,
57            state: None,
58            icc: None,
59        }
60    }
61
62    /// An encoder at an explicit DEFLATE level.
63    #[must_use]
64    pub const fn with_level(level: Level) -> Self {
65        Self {
66            level,
67            state: None,
68            icc: None,
69        }
70    }
71
72    /// An encoder configured from generic encode options.
73    ///
74    /// PNG is lossless, so [`EncodeOptions::quality`] cannot trade fidelity
75    /// for size. It is read as compression *effort* instead, mapping 1..=100
76    /// onto DEFLATE levels 1..=9 — the only axis PNG actually has.
77    #[must_use]
78    pub fn from_options(options: &EncodeOptions) -> Self {
79        let quality = u32::from(options.quality.clamp(1, 100));
80        // 1..=100 onto 1..=9, so the default quality of 80 lands on level 7,
81        // just above zlib's own default of 6.
82        let level = ((quality - 1) * 8 / 99 + 1) as u8;
83        Self::with_level(Level::new(level).unwrap_or(Level::DEFAULT))
84    }
85
86    /// The colour type and bit depth for a pixel format.
87    fn png_type_of(format: PixelFormat) -> Result<(ColorType, u8)> {
88        match format {
89            PixelFormat::Gray8 => Ok((ColorType::Grayscale, 8)),
90            PixelFormat::Gray16 => Ok((ColorType::Grayscale, 16)),
91            PixelFormat::GrayA8 => Ok((ColorType::GrayscaleAlpha, 8)),
92            PixelFormat::Rgb8 => Ok((ColorType::Rgb, 8)),
93            PixelFormat::Rgb16 => Ok((ColorType::Rgb, 16)),
94            PixelFormat::Rgba8 => Ok((ColorType::Rgba, 8)),
95            PixelFormat::Rgba16 => Ok((ColorType::Rgba, 16)),
96            // PNG has no float sample type at any bit depth (§11.2.2).
97            other => Err(PixelsError::unsupported(format!(
98                "PNG cannot represent {other}; convert to an integer format first"
99            ))),
100        }
101    }
102}
103
104impl Default for PngEncoder {
105    fn default() -> Self {
106        Self::new()
107    }
108}
109
110impl State {
111    /// Choose a filter for `row` and append it, filter byte first.
112    ///
113    /// Uses the minimum-sum-of-absolute-differences heuristic from the PNG
114    /// specification's §12.8: filtered bytes near zero compress best, and the
115    /// sum of their signed magnitudes estimates that without running DEFLATE
116    /// five times.
117    fn append_filtered(&mut self, row: &[u8]) {
118        let candidates: &[Filter] = &[
119            Filter::None,
120            Filter::Sub,
121            Filter::Up,
122            Filter::Average,
123            Filter::Paeth,
124        ];
125
126        let mut best = Filter::None;
127        let mut best_score = u64::MAX;
128        let mut scratch = Vec::with_capacity(row.len());
129        for &filter in candidates {
130            scratch.clear();
131            apply_filter(filter, row, &self.previous, self.stride, &mut scratch);
132            let score: u64 = scratch
133                .iter()
134                .map(|&byte| u64::from((byte as i8).unsigned_abs()))
135                .sum();
136            if score < best_score {
137                best_score = score;
138                best = filter;
139            }
140        }
141
142        self.filtered.push(best.to_byte());
143        apply_filter(best, row, &self.previous, self.stride, &mut self.filtered);
144        self.previous.clear();
145        self.previous.extend_from_slice(row);
146    }
147}
148
149/// Rewrite native-endian 16-bit samples as the big-endian ones PNG stores.
150///
151/// On a big-endian host this is a copy; the swap is expressed in terms of
152/// `to_be_bytes` rather than a `cfg`, so there is one code path to be wrong.
153fn to_big_endian_16(row: &[u8], out: &mut Vec<u8>) {
154    out.clear();
155    for pair in row.chunks_exact(2) {
156        let value = u16::from_ne_bytes([
157            pair.first().copied().unwrap_or(0),
158            pair.get(1).copied().unwrap_or(0),
159        ]);
160        out.extend_from_slice(&value.to_be_bytes());
161    }
162}
163
164impl Encoder for PngEncoder {
165    fn set_icc_profile(&mut self, profile: Option<&[u8]>) -> Result<()> {
166        if self.state.is_some() {
167            return Err(PixelsError::invalid_argument(
168                "profile",
169                "the ICC profile must be set before write_header",
170            ));
171        }
172        self.icc = profile.map(<[u8]>::to_vec);
173        Ok(())
174    }
175
176    fn write_header(&mut self, desc: &ImageDescriptor, sink: &mut dyn Sink) -> Result<()> {
177        if self.state.is_some() {
178            return Err(PixelsError::invalid_argument(
179                "descriptor",
180                "write_header called more than once",
181            ));
182        }
183        if desc.width == 0 || desc.height == 0 {
184            return Err(PixelsError::invalid_argument(
185                "descriptor",
186                format!(
187                    "PNG dimensions must be non-zero, got {}x{}",
188                    desc.width, desc.height
189                ),
190            ));
191        }
192        let (color_type, bit_depth) = Self::png_type_of(desc.pixel)?;
193
194        sink.write_all(&SIGNATURE)?;
195        let mut ihdr = Vec::with_capacity(13);
196        ihdr.extend_from_slice(&desc.width.to_be_bytes());
197        ihdr.extend_from_slice(&desc.height.to_be_bytes());
198        ihdr.push(bit_depth);
199        ihdr.push(color_type.to_byte());
200        // Compression 0 (DEFLATE), filter 0 (adaptive), interlace 0 (none) —
201        // the only values the specification defines.
202        ihdr.extend_from_slice(&[0, 0, 0]);
203        let mut chunk = Vec::new();
204        write_chunk(&mut chunk, b"IHDR", &ihdr);
205        if let Some(profile) = &self.icc {
206            // iCCP (§11.3.3.3): a name, NUL, method 0, the zlib stream.
207            let mut iccp = b"ICC Profile\0\0".to_vec();
208            iccp.extend(zlib_compress(profile, self.level).map_err(crate::compress_error)?);
209            write_chunk(&mut chunk, b"iCCP", &iccp);
210        }
211        sink.write_all(&chunk)?;
212
213        let row_bytes = desc.row_bytes();
214        let stride = (color_type.channels() * bit_depth as usize).div_ceil(8);
215        self.state = Some(State {
216            descriptor: *desc,
217            bit_depth,
218            stride,
219            // Each row costs its bytes plus one filter byte.
220            filtered: Vec::with_capacity((row_bytes + 1) * desc.height as usize),
221            previous: vec![0_u8; row_bytes],
222            swapped: Vec::new(),
223            rows_written: 0,
224        });
225        Ok(())
226    }
227
228    fn write_row(&mut self, row: &[u8], _sink: &mut dyn Sink) -> Result<()> {
229        let Some(state) = self.state.as_mut() else {
230            return Err(PixelsError::invalid_argument(
231                "row",
232                "write_row called before write_header",
233            ));
234        };
235        let expected = state.descriptor.row_bytes();
236        if row.len() != expected {
237            return Err(PixelsError::invalid_argument(
238                "row",
239                format!("row is {} bytes, expected {expected}", row.len()),
240            ));
241        }
242        if state.rows_written >= state.descriptor.height {
243            return Err(PixelsError::invalid_argument(
244                "row",
245                format!("more than {} rows written", state.descriptor.height),
246            ));
247        }
248
249        if state.bit_depth == 16 {
250            let mut swapped = std::mem::take(&mut state.swapped);
251            to_big_endian_16(row, &mut swapped);
252            state.append_filtered(&swapped);
253            state.swapped = swapped;
254        } else {
255            state.append_filtered(row);
256        }
257        state.rows_written += 1;
258        Ok(())
259    }
260
261    fn finish(&mut self, sink: &mut dyn Sink) -> Result<()> {
262        let Some(state) = self.state.as_mut() else {
263            return Err(PixelsError::malformed(
264                "png",
265                "finish called before write_header",
266            ));
267        };
268        if state.rows_written != state.descriptor.height {
269            return Err(PixelsError::malformed(
270                "png",
271                format!(
272                    "{} of {} rows written; a partial image is never emitted",
273                    state.rows_written, state.descriptor.height
274                ),
275            ));
276        }
277
278        let compressed =
279            zlib_compress(&state.filtered, self.level).map_err(crate::compress_error)?;
280        let mut chunk = Vec::with_capacity(compressed.len() + 12);
281        write_chunk(&mut chunk, b"IDAT", &compressed);
282        write_chunk(&mut chunk, b"IEND", &[]);
283        sink.write_all(&chunk)?;
284        sink.flush()?;
285
286        // Release the raster now rather than at drop; a caller that keeps the
287        // encoder around to inspect it should not keep the image too.
288        state.filtered = Vec::new();
289        state.previous = Vec::new();
290        state.swapped = Vec::new();
291        Ok(())
292    }
293}
294
295#[cfg(test)]
296#[allow(
297    clippy::unwrap_used,
298    clippy::expect_used,
299    clippy::indexing_slicing,
300    clippy::panic,
301    reason = "tests operate on known-good values and assert shapes directly"
302)]
303mod tests {
304    use super::*;
305    use crate::decoder::PngDecoder;
306    use otf_pixels_core::{Decoder, Limits};
307
308    /// Encode a full raster, returning the PNG bytes.
309    fn encode(desc: &ImageDescriptor, raster: &[u8], level: Level) -> Result<Vec<u8>> {
310        let mut encoder = PngEncoder::with_level(level);
311        let mut out: Vec<u8> = Vec::new();
312        encoder.write_header(desc, &mut out)?;
313        for row in raster.chunks_exact(desc.row_bytes()) {
314            encoder.write_row(row, &mut out)?;
315        }
316        encoder.finish(&mut out)?;
317        Ok(out)
318    }
319
320    /// Decode a full raster back.
321    fn decode(bytes: &[u8]) -> Result<(ImageDescriptor, Vec<u8>)> {
322        let mut decoder = PngDecoder::new(bytes, Limits::default())?;
323        let mut row = vec![0_u8; decoder.descriptor().row_bytes()];
324        let mut raster = Vec::new();
325        for _ in 0..decoder.descriptor().height {
326            decoder.read_row(&mut row)?;
327            raster.extend_from_slice(&row);
328        }
329        Ok((decoder.descriptor(), raster))
330    }
331
332    /// A deterministic non-trivial raster: gradients plus a hard edge, so the
333    /// filter heuristic has something to choose between.
334    fn sample(desc: &ImageDescriptor) -> Vec<u8> {
335        let mut raster = vec![0_u8; desc.byte_len().unwrap()];
336        for (index, byte) in raster.iter_mut().enumerate() {
337            let value = if index % 97 < 40 {
338                (index % 251) as u8
339            } else {
340                ((index * 7) % 13) as u8
341            };
342            *byte = value;
343        }
344        raster
345    }
346
347    #[test]
348    fn every_supported_format_round_trips() {
349        for format in [
350            PixelFormat::Gray8,
351            PixelFormat::Gray16,
352            PixelFormat::GrayA8,
353            PixelFormat::Rgb8,
354            PixelFormat::Rgb16,
355            PixelFormat::Rgba8,
356            PixelFormat::Rgba16,
357        ] {
358            let desc = ImageDescriptor::new(23, 17, format).unwrap();
359            let raster = sample(&desc);
360            let bytes = encode(&desc, &raster, Level::DEFAULT)
361                .unwrap_or_else(|e| panic!("encoding {format}: {e}"));
362            let (decoded_desc, decoded) =
363                decode(&bytes).unwrap_or_else(|e| panic!("decoding {format}: {e}"));
364            assert_eq!(decoded_desc.pixel, format, "{format} changed format");
365            assert_eq!(
366                (decoded_desc.width, decoded_desc.height),
367                (23, 17),
368                "{format}"
369            );
370            assert_eq!(decoded, raster, "{format} did not round-trip");
371        }
372    }
373
374    #[test]
375    fn every_level_round_trips_to_the_same_pixels() {
376        let desc = ImageDescriptor::new(19, 11, PixelFormat::Rgba8).unwrap();
377        let raster = sample(&desc);
378        for level in 0..=9 {
379            let level = Level::new(level).unwrap();
380            let bytes = encode(&desc, &raster, level).unwrap();
381            let (_, decoded) = decode(&bytes).unwrap();
382            assert_eq!(decoded, raster, "level {} did not round-trip", level.get());
383        }
384    }
385
386    #[test]
387    fn the_output_starts_with_the_signature_and_ihdr() {
388        let desc = ImageDescriptor::new(4, 4, PixelFormat::Rgb8).unwrap();
389        let bytes = encode(&desc, &sample(&desc), Level::FAST).unwrap();
390        assert_eq!(&bytes[..8], &SIGNATURE, "signature");
391        assert_eq!(&bytes[12..16], b"IHDR", "first chunk");
392        assert_eq!(
393            &bytes[bytes.len() - 8..bytes.len() - 4],
394            b"IEND",
395            "last chunk"
396        );
397    }
398
399    #[test]
400    fn sixteen_bit_samples_are_written_big_endian() {
401        // The one place a host-endianness bug would hide: encode a known
402        // sample and read the two bytes straight out of the decompressed
403        // scanline via a decode, which is byte-order-symmetric, plus assert
404        // the swap helper directly.
405        let mut out = Vec::new();
406        to_big_endian_16(&0x1234_u16.to_ne_bytes(), &mut out);
407        assert_eq!(out, vec![0x12, 0x34]);
408    }
409
410    #[test]
411    fn compression_shrinks_a_compressible_image() {
412        // A flat image is the clearest case: level 0 stores it, level 9 must
413        // do dramatically better, which proves the level reaches DEFLATE.
414        let desc = ImageDescriptor::new(64, 64, PixelFormat::Rgb8).unwrap();
415        let raster = vec![7_u8; desc.byte_len().unwrap()];
416        let stored = encode(&desc, &raster, Level::NONE).unwrap();
417        let packed = encode(&desc, &raster, Level::BEST).unwrap();
418        assert!(
419            packed.len() * 10 < stored.len(),
420            "level 9 produced {} bytes against level 0's {}",
421            packed.len(),
422            stored.len()
423        );
424        assert_eq!(decode(&packed).unwrap().1, raster);
425    }
426
427    #[test]
428    fn a_float_format_is_unsupported_not_a_panic() {
429        let desc = ImageDescriptor::new(2, 2, PixelFormat::RgbaF32).unwrap();
430        let mut encoder = PngEncoder::new();
431        let mut out = Vec::new();
432        let error = encoder.write_header(&desc, &mut out).unwrap_err();
433        assert!(matches!(error, PixelsError::Unsupported { .. }), "{error}");
434        assert!(
435            out.is_empty(),
436            "nothing should be written for a rejected format"
437        );
438    }
439
440    #[test]
441    fn a_short_image_is_an_error_not_a_truncated_png() {
442        let desc = ImageDescriptor::new(4, 4, PixelFormat::Gray8).unwrap();
443        let mut encoder = PngEncoder::new();
444        let mut out = Vec::new();
445        encoder.write_header(&desc, &mut out).unwrap();
446        encoder.write_row(&[0; 4], &mut out).unwrap();
447        let error = encoder.finish(&mut out).unwrap_err();
448        assert!(matches!(error, PixelsError::Malformed { .. }), "{error}");
449    }
450
451    #[test]
452    fn extra_rows_and_wrong_row_lengths_are_errors() {
453        let desc = ImageDescriptor::new(4, 1, PixelFormat::Gray8).unwrap();
454        let mut encoder = PngEncoder::new();
455        let mut out = Vec::new();
456        encoder.write_header(&desc, &mut out).unwrap();
457        assert!(encoder.write_row(&[0; 3], &mut out).is_err(), "short row");
458        assert!(encoder.write_row(&[0; 5], &mut out).is_err(), "long row");
459        encoder.write_row(&[0; 4], &mut out).unwrap();
460        assert!(encoder.write_row(&[0; 4], &mut out).is_err(), "extra row");
461    }
462
463    #[test]
464    fn rows_before_the_header_are_an_error() {
465        let mut encoder = PngEncoder::new();
466        let mut out = Vec::new();
467        assert!(encoder.write_row(&[0; 4], &mut out).is_err());
468        assert!(encoder.finish(&mut out).is_err());
469    }
470
471    #[test]
472    fn a_second_header_is_an_error() {
473        let desc = ImageDescriptor::new(2, 2, PixelFormat::Gray8).unwrap();
474        let mut encoder = PngEncoder::new();
475        let mut out = Vec::new();
476        encoder.write_header(&desc, &mut out).unwrap();
477        assert!(encoder.write_header(&desc, &mut out).is_err());
478    }
479
480    #[test]
481    fn zero_dimensions_never_reach_the_encoder() {
482        // `ImageDescriptor` refuses to hold a zero dimension, so the guard in
483        // `write_header` is defence in depth rather than the only check. Both
484        // matter: PNG's IHDR has no representation for an empty image, and a
485        // future descriptor change must not quietly start emitting one.
486        assert!(ImageDescriptor::new(0, 4, PixelFormat::Gray8).is_err());
487        assert!(ImageDescriptor::new(4, 0, PixelFormat::Gray8).is_err());
488    }
489
490    #[test]
491    fn options_map_quality_onto_compression_effort() {
492        assert_eq!(
493            PngEncoder::from_options(&EncodeOptions::default())
494                .level
495                .get(),
496            7
497        );
498        let lowest = EncodeOptions::with_quality(1).unwrap();
499        assert_eq!(PngEncoder::from_options(&lowest).level.get(), 1);
500        let highest = EncodeOptions::with_quality(100).unwrap();
501        assert_eq!(PngEncoder::from_options(&highest).level.get(), 9);
502    }
503
504    #[test]
505    fn a_single_pixel_image_round_trips() {
506        // The degenerate case where every filter's neighbours are all zero.
507        let desc = ImageDescriptor::new(1, 1, PixelFormat::Rgba8).unwrap();
508        let raster = vec![1, 2, 3, 4];
509        let bytes = encode(&desc, &raster, Level::BEST).unwrap();
510        assert_eq!(decode(&bytes).unwrap().1, raster);
511    }
512}