From fc9e5898411a2e2e8296de73fceeca556d0acc15 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 06:50:27 -0400 Subject: [PATCH 01/54] feat(png): account every byte of a PNG with deconstruct MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `gamut_png::deconstruct` classifies every byte of a PNG into a typed `Segment` and reports the figures an encoder-efficiency comparison is built from: bits per pixel, what the DEFLATE stage achieved in isolation, how many bytes went to chunk framing, and which scanline filter each row chose. It works on any PNG, whichever encoder wrote it, which is the point: the same numbers can be read off libpng's, oxipng's or zopflipng's output and compared directly. Issue #224 asks for BPP efficiency and parity, and neither is answerable from a total byte count alone -- a size difference has to be attributable to a stage before it can be acted on. Shape follows `gamut_tiff::deconstruct` / `gamut_dng::deconstruct` for the entry point and verdict method, and `gamut_isobmff::segments` for the `Segment { range, kind }` tiling. gamut-png does not and must not depend on gamut-isobmff, and that walk is box-structured anyway, so PNG needs its own -- but the names are deliberately identical. Owned rather than borrowed, unlike the ISOBMFF one. Its segments borrow because they are the only route to an unknown box's bytes; PNG already has `metadata()` for payloads, so the report carries only counts and ranges and can be `Clone + PartialEq + Eq` and stored across a bench corpus without pinning every input buffer alive. Deliberately more tolerant than `metadata()`, which rejects an unknown critical chunk: a measurement tool that refuses to measure is useless. Unknown chunks of either criticality, CRC mismatches, a missing IEND, trailing bytes and a truncated tail are reported, not errored -- `gamut_dng::deconstruct`'s contract verbatim. Only a file with no header to report on fails. The filter histogram is the one part that costs work and can fail, so it is `Option`. The inflation bound needs no policy: PNG's filtered length is *exactly* determined by IHDR, so `max_out` is that length and a zlib bomb cannot exceed it by a byte; a hostile IHDR is handled by declining to inflate past the decoder's existing 64 MiB image budget. Everything else in the report comes from framing and IHDR, so it survives a corrupt, truncated or oversized stream. `RawChunk` gains its own `range`, taken from the offset `ChunkReader` already advances, so byte accounting cannot drift from framing arithmetic; the reader gains an `offset()` so a caller can bound a malformed tail. `PngHeader` gains `PartialEq, Eq` -- additive, and a plain `Copy` header should be comparable. Tests are the byte-accounting law, the family `docs/testing.md` names after `gamut-avif`/`gamut-heic`'s `tests/accounting.rs`. `assert_covers` re-derives the tiling rather than trusting `is_fully_classified`, which is the thing under test. Fixtures come from libpng wherever the claim is about reading a foreign file: interlaced streams, forced filters and sub-byte depths are all things `PngEncoder` cannot write, and a histogram checked against gamut's own filter choice would be self-consistent rather than correct. Two findings from writing them, both recorded in the code: * A trailer counts against `is_intact` even though §13.2 lets a decoder ignore trailing bytes. `bits_per_pixel` divides the whole file by the pixel count, so bytes outside the datastream inflate the headline figure and a size comparison has to know they are there. * The CRC fixture corrupts a stored CRC, not a payload. Corrupting IHDR's payload makes the header unparsable, which is a hard error and a different claim entirely. Refs #224 --- crates/gamut-png/src/chunk.rs | 16 + crates/gamut-png/src/decoded.rs | 2 +- crates/gamut-png/src/deconstruct.rs | 454 +++++++++++++++++++++++++++ crates/gamut-png/src/lib.rs | 4 + crates/gamut-png/tests/accounting.rs | 378 ++++++++++++++++++++++ crates/gamut-png/tests/common/mod.rs | 87 +++++ 6 files changed, 940 insertions(+), 1 deletion(-) create mode 100644 crates/gamut-png/src/deconstruct.rs create mode 100644 crates/gamut-png/tests/accounting.rs diff --git a/crates/gamut-png/src/chunk.rs b/crates/gamut-png/src/chunk.rs index 5516e2a9..67e9cbb5 100644 --- a/crates/gamut-png/src/chunk.rs +++ b/crates/gamut-png/src/chunk.rs @@ -4,6 +4,8 @@ //! covers the type and data. All multi-byte integers in PNG are big-endian — the opposite of the //! DEFLATE/zlib payload the IDAT chunks carry. +use core::ops::Range; + use gamut_core::{Error, Result}; use crate::crc32::Crc32; @@ -30,6 +32,10 @@ pub(crate) struct RawChunk<'a> { pub data: &'a [u8], /// Whether the stored CRC-32 (computed over type + data, §5.5) matched. pub crc_ok: bool, + /// The chunk's whole span in the input, framing included: `12 + data.len()` bytes covering + /// the length, type, payload and CRC fields. Single-sourced from the offset the reader + /// already advances, so byte accounting cannot drift from framing. + pub range: Range, } impl RawChunk<'_> { @@ -105,13 +111,23 @@ impl<'a> ChunkReader<'a> { crc.update(data); let crc_ok = crc.finish().to_be_bytes() == stored; self.rest = rest; + let start = self.offset; self.offset += 12 + length as usize; Ok(Some(RawChunk { chunk_type, data, crc_ok, + range: start..self.offset, })) } + + /// The reader's cursor: the offset of the next chunk header, or — after [`next_chunk`] has + /// returned an error — the start of the malformed one. + /// + /// [`next_chunk`]: Self::next_chunk + pub(crate) fn offset(&self) -> usize { + self.offset + } } #[cfg(test)] diff --git a/crates/gamut-png/src/decoded.rs b/crates/gamut-png/src/decoded.rs index 2d1bbea7..903d7c55 100644 --- a/crates/gamut-png/src/decoded.rs +++ b/crates/gamut-png/src/decoded.rs @@ -21,7 +21,7 @@ use crate::inflate; use crate::palette::PngPalette; /// The parsed image header (IHDR, §11.2.1), reported as stored in the file. -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[non_exhaustive] pub struct PngHeader { /// Image width in pixels. diff --git a/crates/gamut-png/src/deconstruct.rs b/crates/gamut-png/src/deconstruct.rs new file mode 100644 index 00000000..614927c4 --- /dev/null +++ b/crates/gamut-png/src/deconstruct.rs @@ -0,0 +1,454 @@ +//! Where a PNG's bytes went (issue #224): every byte of the file classified into a typed +//! [`Segment`], plus the per-stage figures an encoder-efficiency comparison is built from. +//! +//! This is the measurement counterpart to [`crate::PngEncoder`]. It works on **any** PNG, not +//! just this crate's output, so the same numbers can be read off libpng's, oxipng's or +//! zopflipng's files and compared directly: bits per pixel, what the DEFLATE stage achieved in +//! isolation, how many bytes went to chunk framing, and which scanline filters the encoder +//! actually chose. +//! +//! # The every-byte invariant +//! +//! [`PngReport::segments`] is contiguous, non-overlapping, and covers `0..file_len` exactly. +//! It holds by construction, and [`PngReport::is_fully_classified`] re-derives it from the list +//! rather than storing a flag, so a walk bug makes the predicate false instead of silently +//! agreeing with itself. This mirrors [`gamut_isobmff::segments`]'s guarantee for ISOBMFF; PNG's +//! chunk stream needs its own walk (there are no boxes and no `meta` level), but the shape and +//! the names are deliberately the same. +//! +//! # What is an error and what is a finding +//! +//! Deliberately more tolerant than [`crate::PngDecoder::metadata`], and for the reason +//! [`gamut_dng::deconstruct`] gives: a measurement tool that refuses to measure is useless. +//! Unknown ancillary **and critical** chunks, CRC mismatches, a missing IEND, trailing bytes and +//! a truncated tail are all *reported*, never errors. Only a file with no header to report on — +//! bad signature, no first chunk, a first chunk that is not IHDR, or an unparsable IHDR — fails. + +use core::ops::Range; + +use gamut_core::{Error, Result}; + +use crate::chunk::{ChunkReader, RawChunk, SIGNATURE}; +use crate::decoded::PngHeader; +use crate::filter::FilterType; +use crate::{adam7, ihdr, inflate}; + +/// Chunk framing overhead: 4 length bytes + 4 type bytes + 4 CRC bytes (§5.3). +const FRAMING: usize = 12; + +/// One contiguous run of the input file, tagged by what it holds ([`SegmentKind`]). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Segment { + /// The half-open byte range this segment occupies within the input (`start..end`). + pub range: Range, + /// What the bytes in [`range`](Self::range) are. + pub kind: SegmentKind, +} + +/// What a [`Segment`] holds. +/// +/// Non-exhaustive: a future revision may name a further region (an APNG frame span, say) without +/// a breaking change — match with a wildcard arm. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum SegmentKind { + /// The 8-byte PNG file signature (§5.2). Always the first segment. + Signature, + /// One complete chunk: 4 length bytes, 4 type bytes, the payload, 4 CRC bytes (§5.3), so the + /// segment is always `payload_len + 12` bytes long. + Chunk { + /// The chunk's four-character type, e.g. `*b"IDAT"`. Recognised and unrecognised types + /// alike appear here — critical ones included; the walk never drops a chunk. + chunk_type: [u8; 4], + /// The declared payload length, framing excluded. + payload_len: usize, + /// Whether the stored CRC-32 over type + payload matched (§5.5). A mismatch is reported, + /// never an error: §13.1 makes it recoverable in an ancillary chunk, and the framing is + /// intact either way, so the walk can keep going and account the rest of the file. + crc_ok: bool, + }, + /// Bytes after IEND. Not part of the datastream — §13.2 asks decoders to ignore them, so they + /// are surfaced here rather than silently dropped. + Trailer, + /// From the first chunk header that does not frame — truncated, or declaring a length that + /// overruns the input — to end of file. A file carrying one is not a complete PNG. + Truncated, +} + +/// Per-chunk-type totals, in first-appearance order. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub struct ChunkStats { + /// The chunk's four-character type. + pub chunk_type: [u8; 4], + /// How many chunks of this type the file carries. + pub count: usize, + /// Total payload bytes across those chunks — framing excluded. + pub payload_bytes: usize, +} + +impl ChunkStats { + /// Framing bytes these chunks cost: 12 per chunk (4 length + 4 type + 4 CRC, §5.3). + #[must_use] + pub fn framing_bytes(&self) -> usize { + self.count * FRAMING + } + + /// Payload plus framing — what this chunk type costs the file in total. + #[must_use] + pub fn total_bytes(&self) -> usize { + self.payload_bytes + self.framing_bytes() + } + + /// Whether the type is ancillary — bit 5 of the first byte set, i.e. lowercase (§5.4). + #[must_use] + pub fn is_ancillary(&self) -> bool { + self.chunk_type[0] & 0x20 != 0 + } +} + +/// One reduced image making up the filtered stream: an Adam7 pass (§8.1), or the whole image when +/// the file is not interlaced. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub struct PassStats { + /// Pass index in transmission order (`0..7`); always `0` when the file is not interlaced. + pub index: u8, + /// The reduced image's width in pixels. Never zero: an empty pass carries no bytes at all, + /// not even filter-type bytes (§7.3), so it is omitted entirely. + pub width: u32, + /// The reduced image's height in pixels. Never zero, for the same reason. + pub height: u32, + /// Bytes per scanline excluding the filter-type byte: `ceil(width × bits_per_pixel / 8)`, so + /// a sub-byte depth includes its row padding (§7.2). + pub row_bytes: usize, + /// This pass's contribution to the filtered stream: `height × (1 + row_bytes)`. + pub filtered_len: usize, +} + +/// How many scanlines chose each of the five filters (§9.1), summed over every pass. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FilterHistogram { + counts: [u32; 5], +} + +impl FilterHistogram { + /// Scanlines that chose `filter`. + #[must_use] + pub fn count(self, filter: FilterType) -> u32 { + self.counts[filter as usize] + } + + /// Total scanlines — the sum over all five filters, and the image's scanline count. + #[must_use] + pub fn total(self) -> u32 { + self.counts.iter().sum() + } +} + +/// Where a PNG's bytes went: a total byte accounting plus the figures an encoder-efficiency +/// comparison is built from. Produced by [`deconstruct`]. +/// +/// Non-exhaustive: report categories may be added without a breaking change. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub struct PngReport { + /// The input's total length in bytes — what [`segments`](Self::segments) together covers. + pub file_len: usize, + /// IHDR: dimensions, bit depth, colour type, interlace method. + pub header: PngHeader, + /// Every byte of the input in file order — contiguous, non-overlapping, covering + /// `0..file_len` exactly. See the [every-byte invariant](self#the-every-byte-invariant). + pub segments: Vec, + /// Per-chunk-type totals, in first-appearance order. + pub chunks: Vec, + /// The concatenated IDAT payload length: the zlib codestream, framing excluded. This is what + /// the encoder's compression stage produced, and the numerator of + /// [`idat_ratio`](Self::idat_ratio). + pub idat_compressed: usize, + /// The length that codestream inflates to — the filter-prefixed scanline stream. Derived from + /// IHDR alone (the sum over [`passes`](Self::passes) when interlaced), so it is known even + /// when [`filters`](Self::filters) is `None`. + pub filtered_len: usize, + /// The reduced images making up the filtered stream: one entry per non-empty Adam7 pass, or + /// exactly one entry for a non-interlaced image. + pub passes: Vec, + /// Scanlines per filter type, or `None` when the IDAT stream was not inflated: it was corrupt + /// or truncated, it did not inflate to [`filtered_len`](Self::filtered_len), it carried an + /// undefined filter code, or it was larger than the inflation cap. Everything else in this + /// report is available without inflating. + pub filters: Option, +} + +impl PngReport { + /// **The headline law.** Whether the segments tile the input exactly: the first starts at 0, + /// each ends where the next starts, none is empty, and the last ends at `file_len`. + /// Re-derived from [`segments`](Self::segments) rather than stored. + #[must_use] + pub fn is_fully_classified(&self) -> bool { + let mut expected = 0usize; + for segment in &self.segments { + if segment.range.start != expected || segment.range.end <= segment.range.start { + return false; + } + expected = segment.range.end; + } + expected == self.file_len + } + + /// Whether every byte of this file belongs to a complete, undamaged PNG datastream: fully + /// classified, no [`SegmentKind::Truncated`] and no [`SegmentKind::Trailer`], every CRC + /// valid, IEND present, and the IDAT stream inflated to exactly + /// [`filtered_len`](Self::filtered_len). + /// + /// A trailer counts against it even though §13.2 lets a *decoder* ignore trailing bytes, + /// because [`bits_per_pixel`](Self::bits_per_pixel) divides the whole file by the pixel + /// count: bytes outside the datastream still inflate the headline figure, so a size + /// comparison has to know they are there. + /// + /// Independent of whether every chunk type was *recognised* — an unknown critical chunk is + /// still accounted for. + #[must_use] + pub fn is_intact(&self) -> bool { + self.is_fully_classified() + && self.filters.is_some() + && self.segments.iter().all(|segment| match segment.kind { + SegmentKind::Truncated | SegmentKind::Trailer => false, + SegmentKind::Chunk { crc_ok, .. } => crc_ok, + SegmentKind::Signature => true, + }) + && self.chunk(b"IEND").is_some() + } + + /// **Stored bits per image pixel** — the space-efficiency figure of merit: the whole file, + /// framing and metadata included, over `width × height`. Distinct from the *uncompressed* + /// rate, which is `header.color_type.channels() × header.bit_depth`. + #[must_use] + pub fn bits_per_pixel(&self) -> f64 { + let pixels = f64::from(self.header.width) * f64::from(self.header.height); + // IHDR rejects a zero dimension, so `pixels >= 1.0` for any report that exists. + self.file_len as f64 * 8.0 / pixels + } + + /// The DEFLATE stage's compression ratio in isolation: `idat_compressed / filtered_len`. + /// Below 1.0 means the codestream compressed. Filtering and colour-type choice are *upstream* + /// of this number, which is what makes it the right lens for attributing a size difference to + /// the compressor rather than to the rest of the encoder. + #[must_use] + pub fn idat_ratio(&self) -> f64 { + if self.filtered_len == 0 { + return 0.0; + } + self.idat_compressed as f64 / self.filtered_len as f64 + } + + /// Every byte that is not IDAT payload: the signature, all chunk framing, and every non-IDAT + /// payload. + #[must_use] + pub fn overhead_bytes(&self) -> usize { + self.file_len - self.idat_compressed + } + + /// Total chunk framing: 12 bytes per chunk in the file. + #[must_use] + pub fn framing_bytes(&self) -> usize { + self.chunks.iter().map(ChunkStats::framing_bytes).sum() + } + + /// The stats for one chunk type, if the file carries it. + #[must_use] + pub fn chunk(&self, chunk_type: &[u8; 4]) -> Option { + self.chunks + .iter() + .find(|stats| &stats.chunk_type == chunk_type) + .copied() + } +} + +/// The largest filtered stream this walk will inflate to count filter choices. Matches the +/// decoder's own default image budget, so a report never allocates more than a decode would. +const MAX_FILTERED_BYTES: usize = 64 << 20; + +/// Classifies every byte of `png` and, where the IDAT stream is sound and within budget, counts +/// the scanline filter each row chose. +/// +/// Pixels are never reconstructed: no defiltering, no unpacking, no de-interlacing, no palette +/// resolution. The walk reads chunk framing and the IHDR, and inflates IDAT only to read one +/// filter byte per scanline. +/// +/// Works on any PNG, whichever encoder produced it, which is what makes the figures comparable +/// across encoders (issue #224). +/// +/// # Errors +/// +/// Returns [`Error::InvalidInput`] only when there is no header to report on: a bad signature, no +/// first chunk, a first chunk that is not IHDR, or an IHDR whose payload is invalid. Everything +/// else is **reported, not errored** — unknown ancillary *and critical* chunks, CRC mismatches, a +/// missing IEND, trailing bytes after IEND, a truncated tail, and a corrupt IDAT stream. +pub fn deconstruct(png: &[u8]) -> Result { + let mut reader = ChunkReader::new(png)?; + let mut segments = vec![Segment { + range: 0..SIGNATURE.len(), + kind: SegmentKind::Signature, + }]; + + let first = reader.next_chunk()?.ok_or_else(|| { + Error::invalid_input(env!("CARGO_PKG_NAME"), "PNG: no chunk after the signature") + })?; + if &first.chunk_type != b"IHDR" { + return Err(Error::invalid_input( + env!("CARGO_PKG_NAME"), + "PNG: first chunk is not IHDR", + )); + } + let native = ihdr::parse(first.data)?; + let header = PngHeader { + width: native.width, + height: native.height, + bit_depth: native.bit_depth, + color_type: native.color, + interlaced: native.interlaced, + }; + + let mut chunks: Vec = Vec::new(); + let mut idat = Vec::new(); + let mut saw_iend = false; + let push = |segments: &mut Vec, chunks: &mut Vec, chunk: &RawChunk| { + segments.push(Segment { + range: chunk.range.clone(), + kind: SegmentKind::Chunk { + chunk_type: chunk.chunk_type, + payload_len: chunk.data.len(), + crc_ok: chunk.crc_ok, + }, + }); + match chunks + .iter_mut() + .find(|stats| stats.chunk_type == chunk.chunk_type) + { + Some(stats) => { + stats.count += 1; + stats.payload_bytes += chunk.data.len(); + } + None => chunks.push(ChunkStats { + chunk_type: chunk.chunk_type, + count: 1, + payload_bytes: chunk.data.len(), + }), + } + }; + push(&mut segments, &mut chunks, &first); + + loop { + match reader.next_chunk() { + Ok(None) => break, + Ok(Some(chunk)) => { + if &chunk.chunk_type == b"IDAT" { + idat.extend_from_slice(chunk.data); + } + let is_iend = &chunk.chunk_type == b"IEND"; + push(&mut segments, &mut chunks, &chunk); + if is_iend { + saw_iend = true; + break; + } + } + // A header that does not frame ends the datastream; the rest of the file is + // accounted as one opaque run rather than dropped (§13.2's tolerance, extended to + // damage the spec does not describe). + Err(_) => { + let start = reader.offset(); + if start < png.len() { + segments.push(Segment { + range: start..png.len(), + kind: SegmentKind::Truncated, + }); + } + break; + } + } + } + if saw_iend && reader.offset() < png.len() { + segments.push(Segment { + range: reader.offset()..png.len(), + kind: SegmentKind::Trailer, + }); + } + + let passes = pass_stats(&native); + let filtered_len = adam7::expected_stream_len(&native).unwrap_or(0); + let filters = filter_histogram(&idat, filtered_len, &passes); + + Ok(PngReport { + file_len: png.len(), + header, + segments, + chunks, + idat_compressed: idat.len(), + filtered_len, + passes, + filters, + }) +} + +/// The reduced images making up the filtered stream, skipping empty passes exactly as +/// [`adam7::expected_stream_len`] does — so `filtered_len` is the sum of these and can be checked +/// against it rather than merely asserted. +fn pass_stats(header: &ihdr::Ihdr) -> Vec { + let mut out = Vec::new(); + for (index, pass) in adam7::passes_for(header.interlaced).iter().enumerate() { + let (width, height) = adam7::pass_dimensions(pass, header.width, header.height); + if width == 0 || height == 0 { + continue; + } + let Some(row_bytes) = (width as usize) + .checked_mul(header.bits_per_pixel()) + .map(|bits| bits.div_ceil(8)) + else { + return Vec::new(); + }; + let Some(filtered_len) = row_bytes + .checked_add(1) + .and_then(|stride| (height as usize).checked_mul(stride)) + else { + return Vec::new(); + }; + out.push(PassStats { + index: index as u8, + width, + height, + row_bytes, + filtered_len, + }); + } + out +} + +/// Inflates the IDAT stream and counts the filter byte leading each scanline. +/// +/// `None` whenever the count cannot be trusted: the stream is over budget, corrupt, truncated, +/// inflates to the wrong length, or carries a code §9.1 does not define. Every other figure in +/// the report is derived from framing and IHDR, so it survives all of these. +fn filter_histogram( + idat: &[u8], + filtered_len: usize, + passes: &[PassStats], +) -> Option { + if filtered_len == 0 || filtered_len > MAX_FILTERED_BYTES { + return None; + } + let stream = inflate::inflate_zlib(idat, filtered_len).ok()?; + if stream.len() != filtered_len { + return None; + } + let mut counts = [0u32; 5]; + let mut at = 0usize; + for pass in passes { + for _ in 0..pass.height { + let filter = FilterType::from_code(*stream.get(at)?)?; + counts[filter as usize] += 1; + at += 1 + pass.row_bytes; + } + } + Some(FilterHistogram { counts }) +} diff --git a/crates/gamut-png/src/lib.rs b/crates/gamut-png/src/lib.rs index b7ea0818..7f7f804b 100644 --- a/crates/gamut-png/src/lib.rs +++ b/crates/gamut-png/src/lib.rs @@ -53,6 +53,7 @@ mod color; mod crc32; mod decoded; mod decoder; +mod deconstruct; mod encoder; mod filter; mod ihdr; @@ -69,6 +70,9 @@ pub use decoded::{ Chromaticities, Cicp, DecodedPng, IccProfile, PngHeader, PngImage, PngMetadata, TextChunk, }; pub use decoder::{PngDecoder, TransparencyKey, metadata}; +pub use deconstruct::{ + ChunkStats, FilterHistogram, PassStats, PngReport, Segment, SegmentKind, deconstruct, +}; pub use encoder::PngEncoder; pub use filter::{FilterStrategy, FilterType}; /// The DEFLATE compression level, accepted by [`PngEncoder::with_compression`]. diff --git a/crates/gamut-png/tests/accounting.rs b/crates/gamut-png/tests/accounting.rs new file mode 100644 index 00000000..6f261468 --- /dev/null +++ b/crates/gamut-png/tests/accounting.rs @@ -0,0 +1,378 @@ +//! Byte-accounting totality for [`gamut_png::deconstruct`] (issue #224): every PNG's segments +//! must tile `0..len` exactly, and the reported figures must match what the file actually holds. +//! +//! The fixtures come from **libpng**, not from gamut's encoder, wherever the claim is about +//! reading a foreign file: interlaced streams, forced filters and sub-byte depths are all things +//! `gamut_png::PngEncoder` cannot write, so a gamut-only corpus could not reach them, and a +//! filter histogram checked against gamut's own choice would be self-consistent rather than +//! correct. + +mod common; + +use gamut_core::{Dimensions, EncodeImage, ImageRef, Rgb8}; +use gamut_png::{ + ChunkStats, FilterStrategy, FilterType, PngEncoder, Segment, SegmentKind, deconstruct, +}; + +/// Folds over the segments asserting: non-empty, first starts at 0, each end chains to the next +/// start (contiguous, non-overlapping), and the last ends at `len` — the every-byte invariant. +/// Deliberately re-derived here rather than trusting `is_fully_classified`, which is the thing +/// under test. +fn assert_covers(segments: &[Segment], len: usize) { + assert!(!segments.is_empty(), "at least one segment"); + assert_eq!(segments[0].range.start, 0, "coverage starts at 0"); + for pair in segments.windows(2) { + assert_eq!( + pair[0].range.end, pair[1].range.start, + "segments are contiguous and non-overlapping" + ); + } + assert_eq!( + segments.last().expect("non-empty").range.end, + len, + "coverage runs to end of file" + ); + for s in segments { + assert!(s.range.end > s.range.start, "no empty segment: {s:?}"); + } +} + +/// A deterministic RGB pattern with enough local structure that filters differ between rows. +fn rgb(w: u32, h: u32) -> Vec { + let mut out = Vec::with_capacity((w * h * 3) as usize); + for y in 0..h { + for x in 0..w { + out.push((x ^ y) as u8); + out.push(x.wrapping_mul(3).wrapping_add(y) as u8); + out.push(x.wrapping_add(y.wrapping_mul(7)) as u8); + } + } + out +} + +fn encode_rgb(w: u32, h: u32) -> Vec { + let src = rgb(w, h); + let dims = Dimensions::new(w, h).expect("valid dimensions"); + let image = ImageRef::::new(&src, dims).expect("buffer matches dimensions"); + let mut png = Vec::new(); + PngEncoder::new() + .encode_image(image, &mut png) + .expect("encode"); + png +} + +#[test] +fn segments_tile_every_byte_of_a_gamut_encode() { + for (w, h) in [(1, 1), (17, 13), (64, 40)] { + let png = encode_rgb(w, h); + let report = deconstruct(&png).expect("deconstruct"); + assert_covers(&report.segments, png.len()); + assert!(report.is_fully_classified(), "{report:?}"); + assert!(report.is_intact(), "{report:?}"); + assert_eq!(report.file_len, png.len()); + } +} + +#[test] +fn segments_tile_every_byte_of_every_libpng_colour_type_and_depth() { + for &(color_type, depth) in common::TABLE_12 { + for interlace in [false, true] { + let png = common::libpng_fixture(17, 13, color_type, depth, interlace); + let report = deconstruct(&png).unwrap_or_else(|e| { + panic!("deconstruct ct={color_type} depth={depth} interlace={interlace}: {e:?}") + }); + assert_covers(&report.segments, png.len()); + assert!( + report.is_intact(), + "ct={color_type} depth={depth} interlace={interlace}: {report:?}" + ); + assert_eq!(report.header.bit_depth, depth); + assert_eq!(report.header.interlaced, interlace); + } + } +} + +#[test] +fn chunk_totals_match_an_independent_scan() { + let png = encode_rgb(40, 30); + let report = deconstruct(&png).expect("deconstruct"); + + // A naive second scan written here, so a defect in the walk's accumulation cannot agree with + // itself. 8 signature bytes, then `length || type || data || crc`. + let mut at = 8usize; + let mut seen: Vec<([u8; 4], usize, usize)> = Vec::new(); + while at + 12 <= png.len() { + let len = u32::from_be_bytes([png[at], png[at + 1], png[at + 2], png[at + 3]]) as usize; + let ty = [png[at + 4], png[at + 5], png[at + 6], png[at + 7]]; + match seen.iter_mut().find(|(t, _, _)| *t == ty) { + Some(entry) => { + entry.1 += 1; + entry.2 += len; + } + None => seen.push((ty, 1, len)), + } + at += 12 + len; + } + assert_eq!(at, png.len(), "the naive scan must consume the file too"); + + let got: Vec<_> = report + .chunks + .iter() + .map(|c| (c.chunk_type, c.count, c.payload_bytes)) + .collect(); + assert_eq!(got, seen, "chunk table, in first-appearance order"); + + // Signature + every chunk's payload and framing is the whole file. + let total: usize = report.chunks.iter().map(ChunkStats::total_bytes).sum(); + assert_eq!(total + 8, png.len()); + assert_eq!(report.framing_bytes(), report.chunks.len() * 12); +} + +#[test] +fn trailing_bytes_after_iend_are_a_trailer() { + let mut png = encode_rgb(8, 8); + let clean = png.len(); + png.extend_from_slice(b"junk after the datastream"); + let report = deconstruct(&png).expect("deconstruct"); + + assert_covers(&report.segments, png.len()); + let last = report.segments.last().expect("non-empty"); + assert_eq!(last.kind, SegmentKind::Trailer); + assert_eq!(last.range, clean..png.len()); + // A trailer is not damage the walk failed to classify, but the file is not pristine. + assert!(report.is_fully_classified()); + assert!(!report.is_intact()); +} + +#[test] +fn a_truncated_tail_is_reported_not_an_error() { + let full = encode_rgb(24, 24); + // Cut inside the IDAT payload: the chunk header frames, but its data overruns the input. + let png = &full[..full.len() - 20]; + let report = deconstruct(png).expect("a truncated file still has a header to report on"); + + assert_covers(&report.segments, png.len()); + assert_eq!( + report.segments.last().expect("non-empty").kind, + SegmentKind::Truncated + ); + assert!(report.is_fully_classified()); + assert!(!report.is_intact(), "truncation is not intact"); + // Everything derived from IHDR survives the damage — that is the point of the split. + assert_eq!(report.header.width, 24); + assert!(report.filtered_len > 0); +} + +#[test] +fn unknown_ancillary_and_critical_chunks_are_accounted() { + let extra: [([u8; 4], &[u8]); 2] = [(*b"abCd", &[1, 2, 3]), (*b"ABCD", &[4])]; + let png = common::libpng_with_extra_chunks(12, 9, &extra); + let report = deconstruct(&png).expect("an unknown critical chunk is reported, not an error"); + + assert_covers(&report.segments, png.len()); + let ancillary = report.chunk(b"abCd").expect("unknown ancillary accounted"); + let critical = report.chunk(b"ABCD").expect("unknown critical accounted"); + assert_eq!(ancillary.payload_bytes, 3); + assert_eq!(critical.payload_bytes, 1); + assert!( + ancillary.is_ancillary(), + "lowercase first byte is ancillary" + ); + assert!(!critical.is_ancillary(), "uppercase first byte is critical"); +} + +#[test] +fn a_crc_mismatch_is_flagged_not_fatal() { + let mut png = encode_rgb(8, 8); + // Corrupt IEND's stored CRC, not any payload: every chunk still frames and IHDR still parses, + // so the only thing wrong with the file is a checksum. Corrupting a payload instead would + // make IHDR unparsable, which is a hard error and a different claim. + let last = png.len() - 1; + png[last] ^= 0xFF; + let report = deconstruct(&png).expect("a CRC mismatch is reported, not an error"); + + assert_covers(&report.segments, png.len()); + let bad = report + .segments + .iter() + .filter_map(|s| match s.kind { + SegmentKind::Chunk { + chunk_type, + crc_ok: false, + .. + } => Some(chunk_type), + _ => None, + }) + .collect::>(); + assert_eq!(bad, vec![*b"IEND"], "exactly the damaged chunk is flagged"); + assert!(report.is_fully_classified()); + assert!(!report.is_intact(), "a bad CRC is not intact"); +} + +#[test] +fn the_filter_histogram_matches_the_filter_libpng_was_forced_to_use() { + // libpng, not gamut, picks the filters here, so this cannot be satisfied by a self-consistent + // round trip: it is the differential half of the report's claim. + let forced = [ + (libpng_oracle::FILTER_NONE, FilterType::None), + (libpng_oracle::FILTER_SUB, FilterType::Sub), + (libpng_oracle::FILTER_UP, FilterType::Up), + (libpng_oracle::FILTER_AVG, FilterType::Average), + (libpng_oracle::FILTER_PAETH, FilterType::Paeth), + ]; + for (mask, expected) in &forced { + let png = common::libpng_forced_filter(20, 14, *mask); + let report = deconstruct(&png).expect("deconstruct"); + let filters = report + .filters + .expect("a sound IDAT stream yields a histogram"); + + assert_eq!(filters.total(), 14, "one filter byte per scanline"); + assert_eq!( + filters.count(*expected), + 14, + "every row used {expected:?} (mask {mask:#04x})" + ); + } +} + +#[test] +fn interlaced_filtered_length_is_the_per_pass_sum() { + // 5x3 and 1x1 leave several Adam7 passes empty; an empty pass contributes no bytes at all, + // not even a filter byte (§7.3). + for (w, h) in [(1, 1), (5, 3), (17, 13)] { + let png = common::libpng_fixture(w, h, libpng_oracle::COLOR_RGB, 8, true); + let report = deconstruct(&png).expect("deconstruct"); + + let summed: usize = report.passes.iter().map(|p| p.filtered_len).sum(); + assert_eq!( + summed, report.filtered_len, + "{w}x{h}: passes sum to the whole" + ); + assert!( + report.passes.iter().all(|p| p.width > 0 && p.height > 0), + "empty passes are omitted, not zero-sized: {:?}", + report.passes + ); + + let rows: u32 = report.passes.iter().map(|p| p.height).sum(); + assert_eq!( + report.filters.expect("sound stream").total(), + rows, + "{w}x{h}: one filter byte per scanline of every non-empty pass" + ); + } +} + +#[test] +fn sub_byte_row_padding_is_counted() { + // 5 pixels at depth 4 is 20 bits, which pads to 3 bytes per row -- `div_ceil`, not `/`. + let png = common::libpng_fixture(5, 3, libpng_oracle::COLOR_GRAY, 4, false); + let report = deconstruct(&png).expect("deconstruct"); + + assert_eq!(report.passes.len(), 1, "not interlaced"); + assert_eq!(report.passes[0].row_bytes, 3); + assert_eq!(report.filtered_len, 3 * (3 + 1)); +} + +#[test] +fn a_corrupt_zlib_stream_with_a_valid_crc_yields_no_histogram() { + // The only fixture that falsifies `is_intact`'s `filters.is_some()` conjunct on its own: + // framing is perfect, every CRC is valid, and only the compressed payload is nonsense. + let png = common::png_with_garbage_idat(16, 8); + let report = deconstruct(&png).expect("a corrupt IDAT is reported, not an error"); + + assert_covers(&report.segments, png.len()); + assert!(report.is_fully_classified()); + assert!( + report.segments.iter().all(|s| match s.kind { + SegmentKind::Chunk { crc_ok, .. } => crc_ok, + _ => true, + }), + "every CRC is valid in this fixture" + ); + assert_eq!(report.filters, None, "the histogram is the only casualty"); + assert!(!report.is_intact()); + // Framing- and IHDR-derived figures are unaffected. + assert_eq!(report.header.width, 16); + assert!(report.idat_compressed > 0); + assert!(report.filtered_len > 0); +} + +#[test] +fn an_over_budget_image_reports_everything_but_the_histogram() { + // A hand-built IHDR claiming 2^30 x 2^30 with a tiny IDAT: the filtered stream it implies is + // far past the inflation cap, so the walk must decline to inflate rather than try. Without + // this the cap comparison is never exercised. + let png = common::png_with_huge_ihdr(); + let report = deconstruct(&png).expect("an oversized header is reported, not an error"); + + assert_covers(&report.segments, png.len()); + assert_eq!(report.filters, None, "declined: over the inflation cap"); + assert!( + report.filtered_len > (64 << 20), + "the implied stream is huge" + ); + assert_eq!(report.header.width, 1 << 30); +} + +#[test] +fn a_file_with_no_header_to_report_on_is_an_error() { + assert!(deconstruct(&[]).is_err(), "empty input"); + assert!(deconstruct(b"not a png at all").is_err(), "bad signature"); + + let signature_only = common::SIGNATURE.to_vec(); + assert!(deconstruct(&signature_only).is_err(), "no chunk at all"); + + let mut first_not_ihdr = common::SIGNATURE.to_vec(); + first_not_ihdr.extend_from_slice(&common::chunk(b"gAMA", &45455u32.to_be_bytes())); + assert!( + deconstruct(&first_not_ihdr).is_err(), + "first chunk is not IHDR" + ); + + let mut bad_ihdr = common::SIGNATURE.to_vec(); + bad_ihdr.extend_from_slice(&common::chunk(b"IHDR", &[0u8; 13])); + assert!(deconstruct(&bad_ihdr).is_err(), "zero dimensions in IHDR"); +} + +#[test] +fn the_derived_ratios_are_the_stated_quotients() { + let png = encode_rgb(32, 24); + let report = deconstruct(&png).expect("deconstruct"); + + let pixels = f64::from(report.header.width) * f64::from(report.header.height); + assert!( + (report.bits_per_pixel() - (report.file_len as f64 * 8.0 / pixels)).abs() < 1e-9, + "bits_per_pixel is the whole file over the pixel count" + ); + assert!( + (report.idat_ratio() - (report.idat_compressed as f64 / report.filtered_len as f64)).abs() + < 1e-9, + "idat_ratio is IDAT over the filtered stream" + ); + assert_eq!( + report.overhead_bytes(), + report.file_len - report.idat_compressed + ); + // A real photo-ish pattern must actually compress, or the fixture is not measuring anything. + assert!(report.idat_ratio() < 1.0, "{}", report.idat_ratio()); +} + +#[test] +fn a_brute_force_encode_still_accounts_and_reports_its_filters() { + // The strategy that costs the most and is most likely to trip an accounting assumption. + let src = rgb(48, 32); + let dims = Dimensions::new(48, 32).expect("valid dimensions"); + let image = ImageRef::::new(&src, dims).expect("buffer matches dimensions"); + let mut png = Vec::new(); + PngEncoder::new() + .with_filter(FilterStrategy::BruteForce) + .encode_image(image, &mut png) + .expect("encode"); + + let report = deconstruct(&png).expect("deconstruct"); + assert_covers(&report.segments, png.len()); + assert!(report.is_intact()); + assert_eq!(report.filters.expect("sound stream").total(), 32); +} diff --git a/crates/gamut-png/tests/common/mod.rs b/crates/gamut-png/tests/common/mod.rs index f6c4030b..9b7b90ac 100644 --- a/crates/gamut-png/tests/common/mod.rs +++ b/crates/gamut-png/tests/common/mod.rs @@ -146,3 +146,90 @@ pub fn tiny_exif() -> Vec { 0x49, 0x49, 0x2A, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, ] } + +/// Every valid Table-12 colour-type/bit-depth pair, flattened (libpng's `COLOR_*` codes). +pub const TABLE_12: &[(u8, u8)] = &[ + (libpng_oracle::COLOR_GRAY, 1), + (libpng_oracle::COLOR_GRAY, 2), + (libpng_oracle::COLOR_GRAY, 4), + (libpng_oracle::COLOR_GRAY, 8), + (libpng_oracle::COLOR_GRAY, 16), + (libpng_oracle::COLOR_PALETTE, 1), + (libpng_oracle::COLOR_PALETTE, 2), + (libpng_oracle::COLOR_PALETTE, 4), + (libpng_oracle::COLOR_PALETTE, 8), + (libpng_oracle::COLOR_RGB, 8), + (libpng_oracle::COLOR_RGB, 16), + (libpng_oracle::COLOR_GRAY_ALPHA, 8), + (libpng_oracle::COLOR_GRAY_ALPHA, 16), + (libpng_oracle::COLOR_RGBA, 8), + (libpng_oracle::COLOR_RGBA, 16), +]; + +/// A full-size palette for an indexed fixture at `depth`. +fn full_palette(depth: u8) -> Vec<[u8; 3]> { + (0..(1usize << depth)) + .map(|i| [i as u8, (i * 7 + 3) as u8, 255 - i as u8]) + .collect() +} + +/// Encodes a deterministic fixture with libpng (full-size palette for indexed depths). +pub fn libpng_fixture( + width: u32, + height: u32, + color_type: u8, + depth: u8, + interlace: bool, +) -> Vec { + let pixels = sample_bytes(width, height, color_type, depth, 11); + let palette = full_palette(depth); + let opts = libpng_oracle::EncodeOpts { + interlace, + palette: (color_type == libpng_oracle::COLOR_PALETTE).then_some(&palette), + ..libpng_oracle::EncodeOpts::default() + }; + libpng_oracle::encode(&pixels, width, height, color_type, depth, &opts) +} + +/// An 8-bit RGB fixture libpng wrote with exactly one filter on every scanline. `mask` is one of +/// libpng's `FILTER_*` bits, so the *oracle* chooses the filter, not gamut. +pub fn libpng_forced_filter(width: u32, height: u32, mask: u8) -> Vec { + let pixels = sample_bytes(width, height, libpng_oracle::COLOR_RGB, 8, 11); + let opts = libpng_oracle::EncodeOpts { + filters: Some(mask), + ..libpng_oracle::EncodeOpts::default() + }; + libpng_oracle::encode(&pixels, width, height, libpng_oracle::COLOR_RGB, 8, &opts) +} + +/// An 8-bit RGB fixture carrying extra raw chunks written verbatim after IHDR — used for chunk +/// types this crate does not recognise, ancillary and critical alike. +pub fn libpng_with_extra_chunks(width: u32, height: u32, extra: &[([u8; 4], &[u8])]) -> Vec { + let pixels = sample_bytes(width, height, libpng_oracle::COLOR_RGB, 8, 11); + let opts = libpng_oracle::EncodeOpts { + extra_chunks: extra, + ..libpng_oracle::EncodeOpts::default() + }; + libpng_oracle::encode(&pixels, width, height, libpng_oracle::COLOR_RGB, 8, &opts) +} + +/// A structurally perfect PNG whose IDAT payload is not a zlib stream. Every CRC is valid, so +/// only the *compressed data* is damaged — the one input that isolates a decompression failure +/// from a framing failure. +pub fn png_with_garbage_idat(width: u32, height: u32) -> Vec { + png_from_chunks(&[ + chunk(b"IHDR", &ihdr_payload(width, height, 8, 2, 0)), + chunk(b"IDAT", b"this is not a zlib stream"), + chunk(b"IEND", &[]), + ]) +} + +/// A PNG whose IHDR claims 2^30 x 2^30 with a tiny IDAT: the filtered stream it implies is far +/// past any sane inflation budget, so a reader must decline rather than attempt it. +pub fn png_with_huge_ihdr() -> Vec { + png_from_chunks(&[ + chunk(b"IHDR", &ihdr_payload(1 << 30, 1 << 30, 8, 2, 0)), + chunk(b"IDAT", &zlib(&[0u8; 16])), + chunk(b"IEND", &[]), + ]) +} From 27359a26744335e3c6190e8b463167b4c2214b98 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 13:42:00 -0400 Subject: [PATCH 02/54] refactor(png): expose the encoder stages behind test-support A `benches/` target compiles as a separate crate, so it can only reach `pub` items -- and every encoder stage is crate-private. Timing them one at a time needs a seam. `src/stages.rs` is that seam, and it is re-exports and nothing else. No wrapper bodies: a wrapper would be an executable line no gate ever runs, since bench targets carry `test = false` and neither `cargo test`, `cargo llvm-cov` nor `cargo mutants` reach them. It would drag the coverage floor and generate mutants no test could kill. `.cargo/mutants.toml` already states the rule this follows, in its `crates/gamut/**` entry: "pure feature-gated re-exports (no function bodies), so it carries no logic of its own to mutate." So this needs no new exclusion. The stage items become `pub` inside their still-private modules, which changes no effective visibility -- a `pub` item in a private module is unreachable. With the feature off the crate's public API is byte-identical to before. `test-support` follows the convention gamut-core, gamut-ifd and gamut-tonemap use for their `invariants` modules: additive, `doc(hidden)`, no SemVer guarantee, and never enabled by the `gamut` umbrella, so the shipped surface and `mise run check-ffi-features` are unaffected (both verified). `Crc32::new` gains an `expect(clippy::new_without_default)` rather than a `Default` impl. Nothing in the crate would call such an impl, so it would be an uncovered region and an unkillable mutant -- dead delegation added only to satisfy a lint. Refs #224 --- crates/gamut-png/src/crc32.rs | 15 +++++++++++---- crates/gamut-png/src/filter.rs | 9 +++++++-- crates/gamut-png/src/lib.rs | 5 +++++ crates/gamut-png/src/pack.rs | 7 +------ crates/gamut-png/src/reduce.rs | 6 +++--- crates/gamut-png/src/stages.rs | 22 ++++++++++++++++++++++ 6 files changed, 49 insertions(+), 15 deletions(-) create mode 100644 crates/gamut-png/src/stages.rs diff --git a/crates/gamut-png/src/crc32.rs b/crates/gamut-png/src/crc32.rs index 2be972cd..6b68e3a4 100644 --- a/crates/gamut-png/src/crc32.rs +++ b/crates/gamut-png/src/crc32.rs @@ -28,18 +28,25 @@ const fn build_table() -> [u32; 256] { } /// An incremental CRC-32 accumulator. -pub(crate) struct Crc32 { +pub struct Crc32 { value: u32, } impl Crc32 { /// Starts a fresh CRC (register initialised to all ones). - pub(crate) fn new() -> Self { + // No `Default` impl to pair with this: nothing in the crate would call it, so it would be an + // uncovered region and an unkillable mutant -- a delegation no test can reach. `new` is only + // `pub` so `crate::stages` can re-export it to the benchmark driver. + #[expect( + clippy::new_without_default, + reason = "a Default impl here would be dead delegation: uncovered, and unkillable by any test" + )] + pub fn new() -> Self { Self { value: 0xFFFF_FFFF } } /// Folds `data` into the running CRC. - pub(crate) fn update(&mut self, data: &[u8]) { + pub fn update(&mut self, data: &[u8]) { let mut crc = self.value; for &b in data { crc = TABLE[((crc ^ u32::from(b)) & 0xff) as usize] ^ (crc >> 8); @@ -48,7 +55,7 @@ impl Crc32 { } /// Finalises the CRC (ones-complement of the register). - pub(crate) fn finish(self) -> u32 { + pub fn finish(self) -> u32 { self.value ^ 0xFFFF_FFFF } } diff --git a/crates/gamut-png/src/filter.rs b/crates/gamut-png/src/filter.rs index 8c5c100b..bd60e1d9 100644 --- a/crates/gamut-png/src/filter.rs +++ b/crates/gamut-png/src/filter.rs @@ -120,7 +120,7 @@ fn sum_abs(filtered: &[u8]) -> u64 { /// Filters every scanline of `samples` (row-major, `row_bytes` per row) per `strategy`, producing /// the filter-prefixed byte stream that gets compressed: a filter-type byte then the filtered row, /// for each scanline. `bpp` is the filter stride (bytes per pixel, ≥1). -pub(crate) fn filter_image( +pub fn filter_image( strategy: FilterStrategy, samples: &[u8], row_bytes: usize, @@ -151,7 +151,12 @@ pub(crate) fn filter_image( } /// Picks the filter with the lowest sum-of-absolute-residuals for one scanline. -fn choose_min_sum_abs(cur: &[u8], prev: &[u8], bpp: usize, scratch: &mut Vec) -> FilterType { +pub fn choose_min_sum_abs( + cur: &[u8], + prev: &[u8], + bpp: usize, + scratch: &mut Vec, +) -> FilterType { let mut best = FilterType::None; let mut best_score = u64::MAX; for filter in [ diff --git a/crates/gamut-png/src/lib.rs b/crates/gamut-png/src/lib.rs index 7f7f804b..09e487cf 100644 --- a/crates/gamut-png/src/lib.rs +++ b/crates/gamut-png/src/lib.rs @@ -61,6 +61,11 @@ mod inflate; mod pack; mod palette; mod reduce; +/// The encoder's pipeline stages, re-exported for the out-of-tree benchmark driver (issue #224). +/// Not part of the stable API; see `docs/benchmarking.md`. +#[cfg(feature = "test-support")] +#[doc(hidden)] +pub mod stages; pub use abi::{AbiDeflater, AbiInflater, CODEC_ID_ZLIB, PIXEL_FORMAT_FILTERED_BYTES}; pub use ancillary::{PhysicalUnit, SrgbIntent}; diff --git a/crates/gamut-png/src/pack.rs b/crates/gamut-png/src/pack.rs index 87d5225b..da3c6bbd 100644 --- a/crates/gamut-png/src/pack.rs +++ b/crates/gamut-png/src/pack.rs @@ -17,12 +17,7 @@ pub(crate) fn gray8_scale(bit_depth: u8) -> u8 { /// Packs one-byte-per-sample `samples` (each value `< 2^bit_depth`) into MSB-first bit-packed, /// byte-padded scanlines. `bit_depth` must be 1, 2, or 4. -pub(crate) fn pack_scanlines( - samples: &[u8], - width: usize, - height: usize, - bit_depth: u8, -) -> Vec { +pub fn pack_scanlines(samples: &[u8], width: usize, height: usize, bit_depth: u8) -> Vec { debug_assert!(matches!(bit_depth, 1 | 2 | 4)); let depth = bit_depth as usize; let per_byte = 8 / depth; // samples packed per output byte: 8, 4, or 2 diff --git a/crates/gamut-png/src/reduce.rs b/crates/gamut-png/src/reduce.rs index d0eb25a4..283d4791 100644 --- a/crates/gamut-png/src/reduce.rs +++ b/crates/gamut-png/src/reduce.rs @@ -14,7 +14,7 @@ use std::collections::hash_map::Entry; use crate::pack::gray8_scale; /// A chosen reduced encoding for an image. -pub(crate) enum Reduced { +pub enum Reduced { /// Greyscale at depth 1, 2, 4, or 8 (R=G=B, fully opaque). `samples` holds one byte per pixel: /// the raw value at depth 8, the unscaled code (`value / gray8_scale(depth)`) below it. Gray { @@ -76,7 +76,7 @@ fn pixel_key(px: &[u8], channels: usize) -> [u8; 4] { /// Analyses interleaved 8-bit samples (`channels`: 1 = grey, 2 = grey+alpha, 3 = RGB, 4 = RGBA) /// and returns the smallest lossless reduction that beats the input encoding, or `None` to keep it /// as-is. -pub(crate) fn analyze8(pixels: &[u8], channels: usize) -> Option { +pub fn analyze8(pixels: &[u8], channels: usize) -> Option { debug_assert!((1..=4).contains(&channels)); let pixel_count = pixels.len() / channels; @@ -181,7 +181,7 @@ pub(crate) fn analyze8(pixels: &[u8], channels: usize) -> Option { /// widening) is demoted and re-analysed at 8 bits — the demotion alone halves the payload, so it /// always reduces. Otherwise only the 16-bit-native channel reductions (grey, alpha drop) apply; /// PNG has no 16-bit palette. -pub(crate) fn analyze16(samples: &[u16], channels: usize) -> Option { +pub fn analyze16(samples: &[u16], channels: usize) -> Option { debug_assert!((1..=4).contains(&channels)); if let Some(demoted) = demote16(samples) { let further = analyze8(&demoted, channels); diff --git a/crates/gamut-png/src/stages.rs b/crates/gamut-png/src/stages.rs new file mode 100644 index 00000000..a9d207db --- /dev/null +++ b/crates/gamut-png/src/stages.rs @@ -0,0 +1,22 @@ +//! The encoder's pipeline stages, exposed so they can be timed one at a time (issue #224). +//! +//! A `benches/` target compiles as a separate crate, so it can only reach `pub` items — and the +//! encoder's stages are all crate-private, by design. Rather than widen the shipped API or split +//! working code apart to be reachable, this module re-exports exactly the stage entry points a +//! benchmark drives, behind the `test-support` feature. +//! +//! **No SemVer guarantee.** This is gamut's own harness, not API to pin, and it is `doc(hidden)` +//! for that reason. The `gamut` umbrella never enables the feature, so the shipped surface and +//! `mise run check-ffi-features` are unaffected. +//! +//! It is deliberately re-exports and nothing else — no wrapper bodies. A wrapper would be an +//! executable line that no gate ever runs (bench targets carry `test = false`, so neither +//! `cargo test`, `cargo llvm-cov` nor `cargo mutants` reach them), which would both drag the +//! coverage floor and generate unkillable mutants. `.cargo/mutants.toml` already states the rule +//! this follows, in its `crates/gamut/**` entry: "pure feature-gated re-exports (no function +//! bodies), so it carries no logic of its own to mutate." + +pub use crate::crc32::Crc32; +pub use crate::filter::{choose_min_sum_abs, filter_image}; +pub use crate::pack::pack_scanlines; +pub use crate::reduce::{Reduced, analyze8, analyze16}; From 92a147489bc3fb67f500891ec4dec63a3ba209a5 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 13:42:19 -0400 Subject: [PATCH 03/54] chore(png): benchmark encode size, bpp and per-stage throughput gamut-png was one of the few codec crates with no `benches/` directory, and both `README.md` and `STATUS.md` claimed "output size is benchmarked against libpng at maximum compression" -- a claim no code backed. This is that benchmark. Two tables print before the divan run, following gamut-deflate's and gamut-dng's shape: output size and bits-per-pixel against libpng at zlib level 9, then where the bytes went stage by stage. Every column of both comes from `gamut_png::deconstruct` reading the encoded file back, so the libpng column is a like-for-like measurement rather than two encoders' self-reports, and a size difference can be attributed to filtering, to the colour-type choice, or to DEFLATE. libpng gets the *same source layout* gamut gets, with no `palette` option even for palettisable rows -- handing it a palette would hand it gamut's own reduction and the comparison would stop measuring anything. Its default adaptive filtering is left alone: that is the honest baseline. The measured baseline, recorded here so the next change has something to be judged against (one machine; read the ratios, not the times): input raw default best libpng-9 best/lp9 gradient_rgb8 196608 2831 2272 2393 -5.1% photo_rgb8 196608 29885 20293 27467 -26.1% noise_rgb8 196608 196983 196983 197280 -0.2% grey_as_rgb8 196608 721 370 566 -34.6% palette64_rgba8 262144 1274 715 1102 -35.1% sprite_rgba8 262144 4181 3729 3889 -4.1% flat_rgba8 262144 821 103 664 -84.5% tiny_rgb8 768 136 135 138 -2.2% gamut is smaller than libpng-9 on every row. The stage table shows why, and where it is not: `sprite_rgba8` -- binary alpha over invisible colour noise -- stays TruecolorAlpha where the reduce cascade should reach it, which is exactly the tRNS-colour-key and dirty-alpha gaps this issue is about. Corpus notes, both of which cost a fixture rewrite to get right: * 256x256 is the floor that means anything. RGB at that size is 192 KiB, roughly six times the 32 KiB DEFLATE window, so LZ77 match behaviour is real; a 64x64 image fits *inside* the window and would flatter both encoders equally. * The "incompressible" row is a full avalanche mix, not the plain `i * 2654435761 >> 24` gamut-deflate's bench uses. Over a dense index that top byte changes only once every few hundred `i`, so the first version of this row compressed 97x and measured nothing at all. It now expands slightly, as any lossless codec must on random data. Per-stage rows sit behind `test-support` and are skipped without it, so plain `cargo bench -p gamut-png` and `mise run bench` still work. No `required-features` on the target: `mise run bench` passes no features, and the whole bench would silently never run. Refs #224, #149 --- crates/gamut-png/Cargo.toml | 17 + crates/gamut-png/benches/encode.rs | 508 +++++++++++++++++++++++++++++ 2 files changed, 525 insertions(+) create mode 100644 crates/gamut-png/benches/encode.rs diff --git a/crates/gamut-png/Cargo.toml b/crates/gamut-png/Cargo.toml index 2abaa9d2..640602d3 100644 --- a/crates/gamut-png/Cargo.toml +++ b/crates/gamut-png/Cargo.toml @@ -15,6 +15,14 @@ categories.workspace = true [lints] workspace = true +[features] +# Re-exports the encoder's pipeline stages (`src/stages.rs`) so `benches/encode.rs` -- an external +# crate, which can only see `pub` -- can time them one at a time (issue #224). Additive, +# `doc(hidden)`, no SemVer guarantee, and never enabled by the `gamut` umbrella, so the shipped +# surface and `mise run check-ffi-features` are unaffected. The module is re-exports only, so it +# adds no coverage regions and no mutants. +test-support = [] + [dependencies] gamut-core.workspace = true # The shared codestream-backend seam (issue #272): the `repr(C)` vtable + fallback contract the @@ -33,3 +41,12 @@ miniz_oxide = "0.8" # directions: it decodes the gamut encoder's output, and it generates the fixture corpus (and the # reference pixels) the gamut decoder is differentially checked against. libpng-oracle = { path = "../../tooling/libpng-oracle" } +# Benchmark harness (issue #149) plus this crate's own `test-support` feature, which the bench +# target needs to reach the pipeline stages. A self dev-dependency is the standard way to enable an +# own feature for tests and benches only; `mise run check-release-deps` skips self-edges. +divan.workspace = true +gamut-png = { path = ".", features = ["test-support"] } + +[[bench]] +name = "encode" +harness = false diff --git a/crates/gamut-png/benches/encode.rs b/crates/gamut-png/benches/encode.rs new file mode 100644 index 00000000..8f8e99d3 --- /dev/null +++ b/crates/gamut-png/benches/encode.rs @@ -0,0 +1,508 @@ +//! PNG encode size and throughput benchmarks (issues #224, #149). +//! +//! For a space-efficient encoder two things matter, and they trade against each other: the size it +//! achieves and the time it costs. So `cargo bench -p gamut-png` first prints two tables -- output +//! size and bits-per-pixel against libpng at maximum compression, then where the bytes went stage +//! by stage -- and only then runs the divan throughput benchmarks. +//! +//! Both tables are computed through [`gamut_png::deconstruct`], which reads any PNG whoever wrote +//! it. That is what makes the libpng column a like-for-like comparison rather than two encoders' +//! self-reports, and it is why the stage table can attribute a size difference to filtering, to +//! the colour-type choice, or to DEFLATE. +//! +//! Counters report bytes of *source* pixels per second, so figures are comparable with the other +//! codec suites. Run with `cargo bench -p gamut-png` (or `mise run bench`); add +//! `--features test-support` for the per-stage rows. + +use divan::counter::BytesCount; +use divan::{Bencher, black_box}; +use gamut_core::{Dimensions, EncodeImage, ImageRef, Rgb8, Rgba8}; +use gamut_png::{FilterStrategy, FilterType, Level, PngEncoder, deconstruct}; + +fn main() { + print_size_table(); + print_stage_table(); + divan::main(); +} + +/// Side length of the square test images. +/// +/// 256 is the floor that means anything here: RGB at 256x256 is 192 KiB, roughly six times the +/// 32 KiB DEFLATE window, so LZ77 match behaviour is real. A 64x64 image fits *inside* the window +/// and would flatter both encoders equally, hiding the thing being measured. +const SIDE: u32 = 256; + +/// What a corpus entry is: named pixels in one of the two layouts the tables exercise. +enum Pixels { + /// 8-bit RGB, `SIDE x SIDE`. + Rgb(Vec), + /// 8-bit RGBA, `SIDE x SIDE`. + Rgba(Vec), +} + +/// One named corpus entry. +struct Case { + /// Short name, used as the table's row label and the divan argument. + name: &'static str, + /// Image width in pixels. + width: u32, + /// Image height in pixels. + height: u32, + /// The samples. + pixels: Pixels, +} + +impl Case { + /// Raw sample bytes -- the denominator every ratio in the tables is read against. + fn raw_len(&self) -> usize { + match &self.pixels { + Pixels::Rgb(v) | Pixels::Rgba(v) => v.len(), + } + } + + /// libpng's colour-type code for this entry's layout. + fn libpng_color_type(&self) -> u8 { + match self.pixels { + Pixels::Rgb(_) => libpng_oracle::COLOR_RGB, + Pixels::Rgba(_) => libpng_oracle::COLOR_RGBA, + } + } + + /// Encodes with gamut at the given knobs. + fn gamut(&self, level: Level, filter: FilterStrategy, auto_reduce: bool) -> Vec { + let encoder = PngEncoder::new() + .with_compression(level) + .with_filter(filter) + .with_auto_reduce(auto_reduce); + let dims = Dimensions::new(self.width, self.height).expect("corpus dimensions are valid"); + let mut out = Vec::new(); + match &self.pixels { + Pixels::Rgb(v) => { + let image = ImageRef::::new(v, dims).expect("buffer matches dimensions"); + encoder.encode_image(image, &mut out).expect("encode"); + } + Pixels::Rgba(v) => { + let image = ImageRef::::new(v, dims).expect("buffer matches dimensions"); + encoder.encode_image(image, &mut out).expect("encode"); + } + } + out + } + + /// Encodes the *same source layout* with libpng at zlib level 9. + /// + /// Deliberately no `palette` option even for palettisable entries: handing libpng a palette + /// would hand it gamut's own reduction, and the comparison would stop measuring anything. + /// libpng's default adaptive filtering is left alone -- that is the honest baseline. + fn libpng9(&self) -> Vec { + let samples = match &self.pixels { + Pixels::Rgb(v) | Pixels::Rgba(v) => v.as_slice(), + }; + libpng_oracle::encode( + samples, + self.width, + self.height, + self.libpng_color_type(), + 8, + &libpng_oracle::EncodeOpts { + compression_level: Some(9), + ..libpng_oracle::EncodeOpts::default() + }, + ) + } +} + +/// A deterministic, non-trivial RGB gradient -- the workspace's shared bench pattern. Avoids the +/// all-constant fast paths so the measured work reflects realistic entropy. +fn gradient_rgb(side: u32) -> Vec { + let mut buf = vec![0u8; (side * side * 3) as usize]; + for y in 0..side { + for x in 0..side { + let i = ((y * side + x) * 3) as usize; + buf[i] = (x ^ y) as u8; + buf[i + 1] = x.wrapping_mul(3).wrapping_add(y) as u8; + buf[i + 2] = x.wrapping_add(y.wrapping_mul(7)) as u8; + } + } + buf +} + +/// Smooth, photograph-like content: three integer sinusoid approximations at different periods. +/// Palette-hostile and 16-bit-hostile, so no reduction applies and the residual is the compressor +/// -- this is the row where gamut can lose to libpng, and the one to watch. +fn photo_rgb(side: u32) -> Vec { + let mut buf = vec![0u8; (side * side * 3) as usize]; + for y in 0..side { + for x in 0..side { + let i = ((y * side + x) * 3) as usize; + let (xi, yi) = (i64::from(x), i64::from(y)); + // Triangle waves stand in for sinusoids: smooth, periodic, no float in a fixture. + let tri = |v: i64, period: i64| { + let m = v.rem_euclid(period * 2); + let up = if m < period { m } else { period * 2 - m }; + (up * 255 / period) as u8 + }; + buf[i] = tri(xi + yi, 61); + buf[i + 1] = tri(xi * 2 - yi, 43); + buf[i + 2] = tri(xi + yi * 3, 97); + } + } + buf +} + +/// Incompressible: a full avalanche mix of the byte index. Pins that the encoder does not +/// *expand* random data, and drives `FilterType::None`. +/// +/// Deliberately not the plain `i * 2654435761 >> 24` the deflate bench uses. Over a dense index +/// that top byte changes only once every few hundred `i`, so the "noise" row compressed roughly +/// 97x and measured nothing at all. Three xorshift-multiply rounds give a byte that does not +/// correlate with its neighbours. +fn noise_rgb(side: u32) -> Vec { + (0..(side * side * 3)) + .map(|i: u32| { + let mut v = i.wrapping_add(0x9E37_79B9); + v ^= v >> 16; + v = v.wrapping_mul(0x21F0_AAAD); + v ^= v >> 15; + v = v.wrapping_mul(0x735A_2D97); + v ^= v >> 15; + v as u8 + }) + .collect() +} + +/// Exactly 64 distinct colours over two alpha levels: the indexed + tRNS path, which is gamut's +/// single biggest structural lever over libpng-9 (libpng does not auto-palettise). +fn palette64_rgba(side: u32) -> Vec { + let mut buf = vec![0u8; (side * side * 4) as usize]; + for y in 0..side { + for x in 0..side { + let i = ((y * side + x) * 4) as usize; + let idx = ((x / 8 + y / 8 * 8) % 64) as u8; + buf[i] = idx.wrapping_mul(4); + buf[i + 1] = idx.wrapping_mul(9); + buf[i + 2] = 255 - idx.wrapping_mul(3); + buf[i + 3] = if idx.is_multiple_of(8) { 0 } else { 255 }; + } + } + buf +} + +/// A sprite: binary alpha, and the fully transparent pixels carry *different* RGB values. That +/// invisible colour noise is what today's palette build keys on, so this is the only row that can +/// see the alpha-cleaning and tRNS-colour-key axes. +fn sprite_rgba(side: u32) -> Vec { + let mut buf = vec![0u8; (side * side * 4) as usize]; + for y in 0..side { + for x in 0..side { + let i = ((y * side + x) * 4) as usize; + let cx = i64::from(x) - i64::from(side) / 2; + let cy = i64::from(y) - i64::from(side) / 2; + let inside = cx * cx + cy * cy < (i64::from(side) * i64::from(side)) / 9; + if inside { + buf[i] = (x ^ y) as u8; + buf[i + 1] = 0x40; + buf[i + 2] = 0xC0; + buf[i + 3] = 255; + } else { + // Invisible, and deliberately not constant. + buf[i] = x as u8; + buf[i + 1] = y as u8; + buf[i + 2] = (x ^ y) as u8; + buf[i + 3] = 0; + } + } + } + buf +} + +/// One fully opaque colour: the compressible extreme, where the whole reduce cascade applies and +/// chunk framing is what is left to measure. +fn flat_rgba(side: u32) -> Vec { + (0..(side * side)) + .flat_map(|_| [0x2E, 0x86, 0xC1, 0xFF]) + .collect() +} + +/// A greyscale ramp presented as RGB: R=G=B everywhere, so the grey reduction applies. +fn grey_as_rgb(side: u32) -> Vec { + let mut buf = vec![0u8; (side * side * 3) as usize]; + for y in 0..side { + for x in 0..side { + let i = ((y * side + x) * 3) as usize; + let v = ((x + y) % 256) as u8; + buf[i] = v; + buf[i + 1] = v; + buf[i + 2] = v; + } + } + buf +} + +/// The size-table corpus: one entry per axis that actually changes encoder behaviour. +fn corpus() -> Vec { + let rgb = |name, pixels| Case { + name, + width: SIDE, + height: SIDE, + pixels: Pixels::Rgb(pixels), + }; + let rgba = |name, pixels| Case { + name, + width: SIDE, + height: SIDE, + pixels: Pixels::Rgba(pixels), + }; + vec![ + rgb("gradient_rgb8", gradient_rgb(SIDE)), + rgb("photo_rgb8", photo_rgb(SIDE)), + rgb("noise_rgb8", noise_rgb(SIDE)), + rgb("grey_as_rgb8", grey_as_rgb(SIDE)), + rgba("palette64_rgba8", palette64_rgba(SIDE)), + rgba("sprite_rgba8", sprite_rgba(SIDE)), + rgba("flat_rgba8", flat_rgba(SIDE)), + // The regime where the signature and five chunks of framing dominate bits-per-pixel, and + // the only row where `overhead_bytes` is legible. + Case { + name: "tiny_rgb8", + width: 16, + height: 16, + pixels: Pixels::Rgb(gradient_rgb(16)), + }, + ] +} + +/// The knobs the size table reports gamut under: its default, and its smallest-output setting. +const BEST: (Level, FilterStrategy, bool) = (Level::Best, FilterStrategy::BruteForce, true); + +/// Prints output size and bits-per-pixel against libpng at zlib level 9. +fn print_size_table() { + println!( + "\ngamut-png output size, bytes (lower is better); bpp is the whole file over the pixel count:\n\n\ + {:<17} {:>9} {:>9} {:>9} {:>9} {:>9} {:>7} {:>7}", + "input", "raw", "default", "best", "libpng-9", "best/lp9", "bpp", "lp9 bpp" + ); + for case in corpus() { + let default = case.gamut(Level::Default, FilterStrategy::MinSumAbs, false); + let best = case.gamut(BEST.0, BEST.1, BEST.2); + let libpng = case.libpng9(); + let delta = (best.len() as f64 / libpng.len().max(1) as f64 - 1.0) * 100.0; + let bpp = |bytes: &[u8]| bytes.len() as f64 * 8.0 / f64::from(case.width * case.height); + println!( + "{:<17} {:>9} {:>9} {:>9} {:>9} {:>8.1}% {:>7.3} {:>7.3}", + case.name, + case.raw_len(), + default.len(), + best.len(), + libpng.len(), + delta, + bpp(&best), + bpp(&libpng), + ); + } +} + +/// Prints where gamut's bytes went, stage by stage -- every column read back out of the encoded +/// file through [`deconstruct`], so the table describes the artefact rather than the encoder's +/// own bookkeeping. +fn print_stage_table() { + println!( + "\nwhere the bytes went (gamut at Level::Best + BruteForce + auto-reduce):\n\n\ + {:<17} {:>14} {:>5} {:>10} {:>10} {:>7} {:>9} filters N/S/U/A/P", + "input", "type", "depth", "filtered", "idat", "deflate", "overhead" + ); + for case in corpus() { + let png = case.gamut(BEST.0, BEST.1, BEST.2); + let report = deconstruct(&png).expect("gamut's own output deconstructs"); + let filters = report.filters.map_or_else( + || "-".to_string(), + |h| { + let n = |f| h.count(f); + format!( + "{}/{}/{}/{}/{}", + n(FilterType::None), + n(FilterType::Sub), + n(FilterType::Up), + n(FilterType::Average), + n(FilterType::Paeth) + ) + }, + ); + println!( + "{:<17} {:>14} {:>5} {:>10} {:>10} {:>6.1}% {:>9} {}", + case.name, + format!("{:?}", report.header.color_type), + report.header.bit_depth, + report.filtered_len, + report.idat_compressed, + report.idat_ratio() * 100.0, + report.overhead_bytes(), + filters, + ); + } +} + +fn case_named(name: &str) -> Case { + corpus() + .into_iter() + .find(|c| c.name == name) + .unwrap_or_else(|| panic!("unknown corpus entry {name}")) +} + +#[divan::bench(args = [Level::Fast, Level::Default, Level::Best])] +fn encode_level(bencher: Bencher, level: Level) { + let case = case_named("gradient_rgb8"); + bencher + .counter(BytesCount::new(case.raw_len())) + .bench_local(|| case.gamut(black_box(level), FilterStrategy::MinSumAbs, false)); +} + +#[divan::bench(args = [ + FilterStrategy::None, + FilterStrategy::Fixed(FilterType::Paeth), + FilterStrategy::MinSumAbs, + FilterStrategy::BruteForce, +])] +fn encode_filter_strategy(bencher: Bencher, filter: FilterStrategy) { + let case = case_named("gradient_rgb8"); + bencher + .counter(BytesCount::new(case.raw_len())) + .bench_local(|| case.gamut(Level::Default, black_box(filter), false)); +} + +/// Attributes the whole reduce stage without needing any seam into it: the same image encoded +/// with the analysis on and off. +#[divan::bench(args = [false, true])] +fn encode_auto_reduce(bencher: Bencher, auto_reduce: bool) { + let case = case_named("palette64_rgba8"); + bencher + .counter(BytesCount::new(case.raw_len())) + .bench_local(|| { + case.gamut( + Level::Default, + FilterStrategy::MinSumAbs, + black_box(auto_reduce), + ) + }); +} + +#[divan::bench(args = ["gradient_rgb8", "photo_rgb8", "noise_rgb8", "palette64_rgba8"])] +fn encode_corpus(bencher: Bencher, name: &str) { + let case = case_named(name); + bencher + .counter(BytesCount::new(case.raw_len())) + .bench_local(|| case.gamut(Level::Default, FilterStrategy::MinSumAbs, true)); +} + +/// Reading the accounting back out of a finished file -- the cost every table row pays. +#[divan::bench] +fn deconstruct_a_finished_png(bencher: Bencher) { + let case = case_named("gradient_rgb8"); + let png = case.gamut(Level::Default, FilterStrategy::MinSumAbs, false); + bencher + .counter(BytesCount::new(png.len())) + .bench_local(|| deconstruct(black_box(&png)).expect("deconstruct")); +} + +/// Per-stage rows. Behind `test-support` because a `benches/` target is a separate crate and the +/// encoder's stages are crate-private; see `gamut_png::stages`. +#[cfg(feature = "test-support")] +mod stages { + use gamut_png::stages; + + use super::{Bencher, BytesCount, Case, Pixels, SIDE, black_box, case_named, noise_rgb}; + + /// The filtered stride and row length an RGB8 image of `SIDE` presents. + const BPP: usize = 3; + const ROW_BYTES: usize = SIDE as usize * BPP; + + fn rgb_samples(case: &Case) -> &[u8] { + match &case.pixels { + Pixels::Rgb(v) | Pixels::Rgba(v) => v, + } + } + + #[divan::bench(args = [ + gamut_png::FilterStrategy::None, + gamut_png::FilterStrategy::Fixed(gamut_png::FilterType::Paeth), + gamut_png::FilterStrategy::MinSumAbs, + ])] + fn filter_image(bencher: Bencher, strategy: gamut_png::FilterStrategy) { + let case = case_named("gradient_rgb8"); + let samples = rgb_samples(&case).to_vec(); + bencher + .counter(BytesCount::new(samples.len())) + .bench_local(|| stages::filter_image(black_box(strategy), &samples, ROW_BYTES, BPP)); + } + + /// The per-scanline heuristic in isolation: five trial filterings plus five scorings, per row. + #[divan::bench(args = [1usize, 3, 4])] + fn choose_min_sum_abs(bencher: Bencher, bpp: usize) { + let row: Vec = (0..ROW_BYTES).map(|i| (i * 7) as u8).collect(); + let prev: Vec = (0..ROW_BYTES).map(|i| (i * 13 + 5) as u8).collect(); + bencher + .counter(BytesCount::new(row.len())) + .with_inputs(Vec::new) + .bench_local_refs(|scratch: &mut Vec| { + stages::choose_min_sum_abs(&row, &prev, black_box(bpp), scratch) + }); + } + + #[divan::bench(args = [1u8, 2, 4])] + fn pack_scanlines(bencher: Bencher, depth: u8) { + let samples = vec![1u8; (SIDE * SIDE) as usize]; + bencher + .counter(BytesCount::new(samples.len())) + .bench_local(|| { + stages::pack_scanlines(&samples, SIDE as usize, SIDE as usize, black_box(depth)) + }); + } + + /// Both sides of the auto-reduce early exit: a palettisable image, and one with far more than + /// 256 colours where the scan bails. + #[divan::bench(args = ["palettisable", "too_many_colors"])] + fn analyze8(bencher: Bencher, kind: &str) { + let case = case_named(if kind == "palettisable" { + "palette64_rgba8" + } else { + "photo_rgb8" + }); + let channels = match case.pixels { + Pixels::Rgb(_) => 3, + Pixels::Rgba(_) => 4, + }; + let samples = rgb_samples(&case).to_vec(); + bencher + .counter(BytesCount::new(samples.len())) + .bench_local(|| stages::analyze8(&samples, black_box(channels))); + } + + /// 16-bit analysis, with and without a lawful demotion available: every sample `k * 257` + /// demotes, an arbitrary one does not, and the two take different paths. + #[divan::bench(args = [true, false])] + fn analyze16(bencher: Bencher, demotable: bool) { + let n = (SIDE * SIDE) as usize; + let samples: Vec = (0..n) + .map(|i| { + let v = (i % 256) as u16; + if demotable { v * 257 } else { v * 257 + 1 } + }) + .collect(); + bencher + .counter(BytesCount::new(samples.len() * 2)) + .bench_local(|| stages::analyze16(&samples, black_box(1))); + } + + /// Runs over every IDAT byte, so it is on the critical path of every encode. + #[divan::bench] + fn crc32(bencher: Bencher) { + let data = noise_rgb(SIDE); + bencher + .counter(BytesCount::new(data.len())) + .bench_local(|| { + let mut crc = stages::Crc32::new(); + crc.update(black_box(&data)); + crc.finish() + }); + } +} From 78466a18ca2db1c2a01a743dde296f5f7a8090b5 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 13:45:57 -0400 Subject: [PATCH 04/54] test(png): pin the output size against libpng at maximum compression `README.md` and `STATUS.md` have long claimed "output size is benchmarked against libpng at maximum compression". The previous commit prints that comparison, but a bench asserts nothing and does not run in the per-PR gate. This makes the claim enforceable: a regression in the crate's reason to exist fails the build, the same mechanism gamut-deflate's ratio contract and gamut-webp/tests/effort.rs use. Every budget carries its own written justification naming the stage that spends the bytes, in the shape of gamut-cmm's precision-budget table, and records what the row measured when the budget was set so drift shows up in review rather than as a surprise red build. Measured at 128x128 -- half the bench's side, so this stays fast enough for the coverage and mutation lanes. row gamut libpng-9 ratio budget gradient_rgb8 703 749 0.939 0.98 photo_rgb8 5843 7768 0.752 0.85 noise_rgb8 49348 49435 0.998 1.01 grey_as_rgb8 146 251 0.582 0.70 flat_rgba8 96 299 0.321 0.45 sprite_rgba8 1669 1733 0.963 1.00 palette64_rgba8 451 405 1.114 1.15 The last row is the finding, and the budget records it rather than hiding it. gamut auto-palettises where libpng writes RGBA: at 256x256 that wins by 35%, at 128x128 it loses by 11%. Measured with `deconstruct` across four sizes: side gamut IDAT PLTE+tRNS libpng-9 128 451 121 273 405 160 511 181 273 572 192 564 234 273 707 256 715 385 273 1102 The cause is not that `reduce::analyze8` ignores the palette chunks -- it counts them, estimating 280 bytes against an actual 273. It is that the model compares *raw* sizes, and raw size does not predict compressed size when one candidate's bytes are incompressible and the other's are not. Those 273 bytes survive DEFLATE intact while the RGBA alternative compresses roughly 160x, so the estimate sees 16 664 against 65 536 and picks palette by a 4x margin that does not survive compression. The crossover sits near 160x160. Filed separately; a cost model that weighs incompressible overhead against compressible pixels is what tightens that budget. Four tests, each failing for one reason: the budget table, a strictly-smaller assertion for the rows that claim a structural win, an attribution test, and determinism. The winning set is listed explicitly rather than derived from `max_ratio < 1.0` -- a budget loosened past 1.0 during a regression would otherwise drop out of that test silently, which is exactly when it should fail. Not hypothetical: palette64 was in the derived set before it was measured. The attribution test is why `deconstruct` is a dependency here. Where both encoders land on the same colour type and depth the filtered stream is identical by construction, so comparing the *compressed* streams isolates DEFLATE from filtering and from the colour-type choice. The corpus moves to `tests/common/corpus.rs` and the bench includes it by path. Budgets are only meaningful measured on the same pixels the table reports, and two copies would drift invisibly -- a budget that no longer describes the row it names. libpng gets the same source layout with no palette hint and its own default adaptive filtering. Handing it a palette would hand it gamut's reduction. Refs #224 --- crates/gamut-png/benches/encode.rs | 136 +------------ crates/gamut-png/tests/common/corpus.rs | 143 ++++++++++++++ crates/gamut-png/tests/common/mod.rs | 3 + crates/gamut-png/tests/size_contract.rs | 247 ++++++++++++++++++++++++ 4 files changed, 402 insertions(+), 127 deletions(-) create mode 100644 crates/gamut-png/tests/common/corpus.rs create mode 100644 crates/gamut-png/tests/size_contract.rs diff --git a/crates/gamut-png/benches/encode.rs b/crates/gamut-png/benches/encode.rs index 8f8e99d3..2ac31b70 100644 --- a/crates/gamut-png/benches/encode.rs +++ b/crates/gamut-png/benches/encode.rs @@ -19,6 +19,15 @@ use divan::{Bencher, black_box}; use gamut_core::{Dimensions, EncodeImage, ImageRef, Rgb8, Rgba8}; use gamut_png::{FilterStrategy, FilterType, Level, PngEncoder, deconstruct}; +// The corpus lives with the size contract that asserts against it, so the budgets in +// `tests/size_contract.rs` and the table printed here can never describe different pixels. +#[path = "../tests/common/corpus.rs"] +mod corpus; + +use corpus::{ + flat_rgba, gradient_rgb, grey_as_rgb, noise_rgb, palette64_rgba, photo_rgb, sprite_rgba, +}; + fn main() { print_size_table(); print_stage_table(); @@ -112,133 +121,6 @@ impl Case { } } -/// A deterministic, non-trivial RGB gradient -- the workspace's shared bench pattern. Avoids the -/// all-constant fast paths so the measured work reflects realistic entropy. -fn gradient_rgb(side: u32) -> Vec { - let mut buf = vec![0u8; (side * side * 3) as usize]; - for y in 0..side { - for x in 0..side { - let i = ((y * side + x) * 3) as usize; - buf[i] = (x ^ y) as u8; - buf[i + 1] = x.wrapping_mul(3).wrapping_add(y) as u8; - buf[i + 2] = x.wrapping_add(y.wrapping_mul(7)) as u8; - } - } - buf -} - -/// Smooth, photograph-like content: three integer sinusoid approximations at different periods. -/// Palette-hostile and 16-bit-hostile, so no reduction applies and the residual is the compressor -/// -- this is the row where gamut can lose to libpng, and the one to watch. -fn photo_rgb(side: u32) -> Vec { - let mut buf = vec![0u8; (side * side * 3) as usize]; - for y in 0..side { - for x in 0..side { - let i = ((y * side + x) * 3) as usize; - let (xi, yi) = (i64::from(x), i64::from(y)); - // Triangle waves stand in for sinusoids: smooth, periodic, no float in a fixture. - let tri = |v: i64, period: i64| { - let m = v.rem_euclid(period * 2); - let up = if m < period { m } else { period * 2 - m }; - (up * 255 / period) as u8 - }; - buf[i] = tri(xi + yi, 61); - buf[i + 1] = tri(xi * 2 - yi, 43); - buf[i + 2] = tri(xi + yi * 3, 97); - } - } - buf -} - -/// Incompressible: a full avalanche mix of the byte index. Pins that the encoder does not -/// *expand* random data, and drives `FilterType::None`. -/// -/// Deliberately not the plain `i * 2654435761 >> 24` the deflate bench uses. Over a dense index -/// that top byte changes only once every few hundred `i`, so the "noise" row compressed roughly -/// 97x and measured nothing at all. Three xorshift-multiply rounds give a byte that does not -/// correlate with its neighbours. -fn noise_rgb(side: u32) -> Vec { - (0..(side * side * 3)) - .map(|i: u32| { - let mut v = i.wrapping_add(0x9E37_79B9); - v ^= v >> 16; - v = v.wrapping_mul(0x21F0_AAAD); - v ^= v >> 15; - v = v.wrapping_mul(0x735A_2D97); - v ^= v >> 15; - v as u8 - }) - .collect() -} - -/// Exactly 64 distinct colours over two alpha levels: the indexed + tRNS path, which is gamut's -/// single biggest structural lever over libpng-9 (libpng does not auto-palettise). -fn palette64_rgba(side: u32) -> Vec { - let mut buf = vec![0u8; (side * side * 4) as usize]; - for y in 0..side { - for x in 0..side { - let i = ((y * side + x) * 4) as usize; - let idx = ((x / 8 + y / 8 * 8) % 64) as u8; - buf[i] = idx.wrapping_mul(4); - buf[i + 1] = idx.wrapping_mul(9); - buf[i + 2] = 255 - idx.wrapping_mul(3); - buf[i + 3] = if idx.is_multiple_of(8) { 0 } else { 255 }; - } - } - buf -} - -/// A sprite: binary alpha, and the fully transparent pixels carry *different* RGB values. That -/// invisible colour noise is what today's palette build keys on, so this is the only row that can -/// see the alpha-cleaning and tRNS-colour-key axes. -fn sprite_rgba(side: u32) -> Vec { - let mut buf = vec![0u8; (side * side * 4) as usize]; - for y in 0..side { - for x in 0..side { - let i = ((y * side + x) * 4) as usize; - let cx = i64::from(x) - i64::from(side) / 2; - let cy = i64::from(y) - i64::from(side) / 2; - let inside = cx * cx + cy * cy < (i64::from(side) * i64::from(side)) / 9; - if inside { - buf[i] = (x ^ y) as u8; - buf[i + 1] = 0x40; - buf[i + 2] = 0xC0; - buf[i + 3] = 255; - } else { - // Invisible, and deliberately not constant. - buf[i] = x as u8; - buf[i + 1] = y as u8; - buf[i + 2] = (x ^ y) as u8; - buf[i + 3] = 0; - } - } - } - buf -} - -/// One fully opaque colour: the compressible extreme, where the whole reduce cascade applies and -/// chunk framing is what is left to measure. -fn flat_rgba(side: u32) -> Vec { - (0..(side * side)) - .flat_map(|_| [0x2E, 0x86, 0xC1, 0xFF]) - .collect() -} - -/// A greyscale ramp presented as RGB: R=G=B everywhere, so the grey reduction applies. -fn grey_as_rgb(side: u32) -> Vec { - let mut buf = vec![0u8; (side * side * 3) as usize]; - for y in 0..side { - for x in 0..side { - let i = ((y * side + x) * 3) as usize; - let v = ((x + y) % 256) as u8; - buf[i] = v; - buf[i + 1] = v; - buf[i + 2] = v; - } - } - buf -} - /// The size-table corpus: one entry per axis that actually changes encoder behaviour. fn corpus() -> Vec { let rgb = |name, pixels| Case { diff --git a/crates/gamut-png/tests/common/corpus.rs b/crates/gamut-png/tests/common/corpus.rs new file mode 100644 index 00000000..9883db50 --- /dev/null +++ b/crates/gamut-png/tests/common/corpus.rs @@ -0,0 +1,143 @@ +//! The efficiency corpus (issue #224): deterministic image generators shared by +//! `benches/encode.rs` and `tests/size_contract.rs`. +//! +//! One file, included by both, because the size contract's budgets are only meaningful if they +//! are measured on the same pixels the benchmark table reports. Two copies would drift, and the +//! drift would be invisible — a budget that no longer describes the row it names. +//! +//! Dependency-free on purpose: the benchmark includes it with `#[path]`, so it must not reach for +//! anything outside `core`/`alloc`. +//! +//! Each generator is one axis of encoder behaviour, and no two overlap. There is no vendored +//! image corpus in this crate (`README.md` says so), so every fixture is generated. + +#![allow(dead_code)] + +/// A deterministic, non-trivial RGB gradient — the workspace's shared bench pattern. Avoids the +/// all-constant fast paths so the measured work reflects realistic entropy. +pub fn gradient_rgb(side: u32) -> Vec { + let mut buf = vec![0u8; (side * side * 3) as usize]; + for y in 0..side { + for x in 0..side { + let i = ((y * side + x) * 3) as usize; + buf[i] = (x ^ y) as u8; + buf[i + 1] = x.wrapping_mul(3).wrapping_add(y) as u8; + buf[i + 2] = x.wrapping_add(y.wrapping_mul(7)) as u8; + } + } + buf +} + +/// Smooth, photograph-like content: three triangle waves at co-prime periods standing in for +/// sinusoids (no floating point in a fixture). Palette-hostile and 16-bit-hostile, so no reduction +/// applies and the whole residual is filtering plus DEFLATE — the row that measures the +/// compressor rather than the analysis. +pub fn photo_rgb(side: u32) -> Vec { + let tri = |v: i64, period: i64| { + let m = v.rem_euclid(period * 2); + let up = if m < period { m } else { period * 2 - m }; + (up * 255 / period) as u8 + }; + let mut buf = vec![0u8; (side * side * 3) as usize]; + for y in 0..side { + for x in 0..side { + let i = ((y * side + x) * 3) as usize; + let (xi, yi) = (i64::from(x), i64::from(y)); + buf[i] = tri(xi + yi, 61); + buf[i + 1] = tri(xi * 2 - yi, 43); + buf[i + 2] = tri(xi + yi * 3, 97); + } + } + buf +} + +/// Incompressible: a full avalanche mix of the byte index. Pins that the encoder does not +/// *expand* random data by more than stored-block framing, and drives `FilterType::None`. +/// +/// Deliberately not the plain `i * 2654435761 >> 24` that `gamut-deflate`'s bench uses. Over a +/// dense index that top byte changes only once every few hundred `i`, so a "noise" row built that +/// way compresses roughly 97x and measures nothing at all. +pub fn noise_rgb(side: u32) -> Vec { + (0..(side * side * 3)) + .map(|i: u32| { + let mut v = i.wrapping_add(0x9E37_79B9); + v ^= v >> 16; + v = v.wrapping_mul(0x21F0_AAAD); + v ^= v >> 15; + v = v.wrapping_mul(0x735A_2D97); + v ^= v >> 15; + v as u8 + }) + .collect() +} + +/// A greyscale ramp presented as RGB: R=G=B everywhere, so the grey reduction applies and two +/// channels disappear before DEFLATE runs. +pub fn grey_as_rgb(side: u32) -> Vec { + let mut buf = vec![0u8; (side * side * 3) as usize]; + for y in 0..side { + for x in 0..side { + let i = ((y * side + x) * 3) as usize; + let v = ((x + y) % 256) as u8; + buf[i] = v; + buf[i + 1] = v; + buf[i + 2] = v; + } + } + buf +} + +/// Exactly 64 distinct colours over two alpha levels: the indexed + tRNS path, which is the +/// biggest structural lever this crate has over libpng-9 (libpng does not auto-palettise). +pub fn palette64_rgba(side: u32) -> Vec { + let mut buf = vec![0u8; (side * side * 4) as usize]; + for y in 0..side { + for x in 0..side { + let i = ((y * side + x) * 4) as usize; + let idx = ((x / 8 + y / 8 * 8) % 64) as u8; + buf[i] = idx.wrapping_mul(4); + buf[i + 1] = idx.wrapping_mul(9); + buf[i + 2] = 255 - idx.wrapping_mul(3); + buf[i + 3] = if idx.is_multiple_of(8) { 0 } else { 255 }; + } + } + buf +} + +/// A sprite: binary alpha, where the fully transparent pixels carry *different* RGB values. +/// +/// That invisible colour noise is what the palette build keys on today, so this is the only entry +/// that can see the dirty-alpha and tRNS-colour-key axes. It is the row to watch when either +/// lands. +pub fn sprite_rgba(side: u32) -> Vec { + let mut buf = vec![0u8; (side * side * 4) as usize]; + let r2 = (i64::from(side) * i64::from(side)) / 9; + for y in 0..side { + for x in 0..side { + let i = ((y * side + x) * 4) as usize; + let cx = i64::from(x) - i64::from(side) / 2; + let cy = i64::from(y) - i64::from(side) / 2; + if cx * cx + cy * cy < r2 { + buf[i] = (x ^ y) as u8; + buf[i + 1] = 0x40; + buf[i + 2] = 0xC0; + buf[i + 3] = 255; + } else { + // Invisible, and deliberately not constant. + buf[i] = x as u8; + buf[i + 1] = y as u8; + buf[i + 2] = (x ^ y) as u8; + buf[i + 3] = 0; + } + } + } + buf +} + +/// One fully opaque colour: the compressible extreme, where the whole reduce cascade applies and +/// chunk framing is most of what is left to measure. +pub fn flat_rgba(side: u32) -> Vec { + (0..(side * side)) + .flat_map(|_| [0x2E, 0x86, 0xC1, 0xFF]) + .collect() +} diff --git a/crates/gamut-png/tests/common/mod.rs b/crates/gamut-png/tests/common/mod.rs index 9b7b90ac..c571d18a 100644 --- a/crates/gamut-png/tests/common/mod.rs +++ b/crates/gamut-png/tests/common/mod.rs @@ -3,6 +3,9 @@ //! CRC-32 so the builders do not depend on the crate under test. #![allow(dead_code)] // each integration-test binary uses its own subset +/// The efficiency corpus, shared with `benches/encode.rs` (issue #224). +pub mod corpus; + /// The 8-byte PNG signature. pub const SIGNATURE: [u8; 8] = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; diff --git a/crates/gamut-png/tests/size_contract.rs b/crates/gamut-png/tests/size_contract.rs new file mode 100644 index 00000000..8f598499 --- /dev/null +++ b/crates/gamut-png/tests/size_contract.rs @@ -0,0 +1,247 @@ +//! The size contract (issue #224): gamut's output measured against libpng at zlib level 9, with a +//! per-case budget that each carries its own written justification. +//! +//! `README.md` and `STATUS.md` have long claimed "output size is benchmarked against libpng at +//! maximum compression". `benches/encode.rs` now prints that comparison, but a bench asserts +//! nothing and is not in the per-PR gate. This file is what makes the claim enforceable: a +//! regression in the crate's reason to exist fails the build, which is the same mechanism +//! `gamut-deflate`'s ratio contract and `gamut-webp/tests/effort.rs` use. +//! +//! Budgets are *measured*, not aspirational, and they are one-sided. The table below records what +//! each case actually achieves alongside what is asserted, so drift shows up in review rather +//! than as a surprise red build. Deliberately no "budgets are still tight" assertion: it would +//! fail on a libpng point release for no correctness reason. + +mod common; + +use gamut_core::{Dimensions, EncodeImage, ImageRef, Rgb8, Rgba8}; +use gamut_png::{FilterStrategy, Level, PngEncoder, deconstruct}; + +/// One case's size budget against libpng at zlib level 9. +struct Budget { + /// Corpus entry name; matches `benches/encode.rs`. + name: &'static str, + /// The most gamut's file may measure as a fraction of libpng's. `1.00` reads "never larger". + max_ratio: f64, + /// What the case measured when the budget was set, so drift is visible in review. + measured: f64, + /// Why this number and not a tighter one — which stage spends the bytes. + why: &'static str, +} + +/// Every budget carries its justification. Measured at 128x128 (half the bench's side, so the +/// suite stays quick enough for the coverage and mutation lanes); the ratios track the bench's +/// 256x256 figures closely but are not identical, which is why they are recorded separately. +const BUDGETS: &[Budget] = &[ + Budget { + name: "gradient_rgb8", + max_ratio: 0.98, + measured: 0.939, + why: "no reduction applies, so this is filtering plus DEFLATE against libpng's own \ + adaptive filtering. The margin is thin by nature -- both encoders are doing the \ + same job -- so the budget only guards against losing outright.", + }, + Budget { + name: "photo_rgb8", + max_ratio: 0.85, + measured: 0.752, + why: "smooth photographic content: palette-hostile, so again pure filtering + DEFLATE, \ + and the win is the optimal parse. Coupled to gamut-deflate's own Best/z9 column by \ + construction: if that regresses, this row moves with it. Headroom is wider than \ + the others for that reason.", + }, + Budget { + name: "noise_rgb8", + max_ratio: 1.01, + measured: 0.998, + why: "incompressible, so both encoders fall back to stored blocks and the file is \ + slightly larger than the raw samples. Above 1.0 because there is nothing to win \ + here, not because we lose; the margin covers stored-block framing only.", + }, + Budget { + name: "grey_as_rgb8", + max_ratio: 0.70, + measured: 0.582, + why: "R=G=B everywhere, so auto-reduce drops two channels before DEFLATE runs. A \ + structural win libpng does not attempt.", + }, + Budget { + name: "flat_rgba8", + max_ratio: 0.45, + measured: 0.321, + why: "one opaque colour: the reduce cascade collapses it to depth-1 indexed, and chunk \ + framing is most of what remains.", + }, + Budget { + name: "sprite_rgba8", + max_ratio: 1.00, + measured: 0.963, + why: "binary alpha over invisible colour noise. Deliberately loose: the reduce cascade \ + does not reach this case today -- no tRNS colour key, no dirty-alpha cleaning -- so \ + the margin is thin. Tightening it is the acceptance test for those two axes.", + }, + Budget { + name: "palette64_rgba8", + max_ratio: 1.15, + measured: 1.114, + // The one row where gamut is *larger* than libpng, and the budget says so rather than + // hiding it. A real defect the measurement found, filed separately. + why: "gamut auto-palettises (64 colours over two alpha levels); libpng-9 writes full \ + RGBA. At 256x256 that wins by 35%, but at 128x128 it LOSES by 11%. Not because \ + `reduce::analyze8` ignores the palette chunks -- it does count them -- but because \ + it compares *raw* sizes, and raw size does not predict compressed size when one \ + candidate's bytes are incompressible and the other's are not. Measured: PLTE + \ + tRNS is a flat 273 bytes that DEFLATE cannot touch, while the indexed pixel data \ + compresses to 121 and the RGBA alternative libpng writes compresses to 405 total. \ + The estimate sees 16 664 against 65 536 and picks palette by 4x; the crossover is \ + near 160x160. The budget records the loss; a cost model that weighs incompressible \ + overhead against compressible pixels is what tightens it.", +]; + +/// Half the bench's side, so this file stays fast enough for the coverage and mutation lanes. +const SIDE: u32 = 128; + +/// The pixels for a budget row, and how many channels they carry. +fn pixels(name: &str) -> (Vec, usize) { + let side = SIDE; + match name { + "gradient_rgb8" => (common::corpus::gradient_rgb(side), 3), + "photo_rgb8" => (common::corpus::photo_rgb(side), 3), + "noise_rgb8" => (common::corpus::noise_rgb(side), 3), + "grey_as_rgb8" => (common::corpus::grey_as_rgb(side), 3), + "palette64_rgba8" => (common::corpus::palette64_rgba(side), 4), + "sprite_rgba8" => (common::corpus::sprite_rgba(side), 4), + "flat_rgba8" => (common::corpus::flat_rgba(side), 4), + other => panic!("unknown budget row {other}"), + } +} + +/// Encodes at the crate's smallest-output settings. +fn gamut_best(samples: &[u8], channels: usize) -> Vec { + let encoder = PngEncoder::new() + .with_compression(Level::Best) + .with_filter(FilterStrategy::BruteForce) + .with_auto_reduce(true); + let dims = Dimensions::new(SIDE, SIDE).expect("valid dimensions"); + let mut out = Vec::new(); + if channels == 3 { + let image = ImageRef::::new(samples, dims).expect("buffer matches dimensions"); + encoder.encode_image(image, &mut out).expect("encode"); + } else { + let image = ImageRef::::new(samples, dims).expect("buffer matches dimensions"); + encoder.encode_image(image, &mut out).expect("encode"); + } + out +} + +/// The same source layout through libpng at zlib level 9 — no palette hint, default adaptive +/// filtering. Handing libpng a palette would hand it gamut's own reduction. +fn libpng9(samples: &[u8], channels: usize) -> Vec { + let color_type = if channels == 3 { + libpng_oracle::COLOR_RGB + } else { + libpng_oracle::COLOR_RGBA + }; + libpng_oracle::encode( + samples, + SIDE, + SIDE, + color_type, + 8, + &libpng_oracle::EncodeOpts { + compression_level: Some(9), + ..libpng_oracle::EncodeOpts::default() + }, + ) +} + +#[test] +fn gamut_never_exceeds_its_size_budget_against_libpng9() { + for budget in BUDGETS { + let (samples, channels) = pixels(budget.name); + let ours = gamut_best(&samples, channels); + let theirs = libpng9(&samples, channels); + let ratio = ours.len() as f64 / theirs.len() as f64; + assert!( + ratio <= budget.max_ratio, + "{}: {} bytes vs libpng-9's {} = {ratio:.3}, budget {:.2} (measured {:.2} when set)\n {}", + budget.name, + ours.len(), + theirs.len(), + budget.max_ratio, + budget.measured, + budget.why, + ); + } +} + +#[test] +fn gamut_beats_libpng9_where_it_claims_to() { + // "We win here" and "we do not lose too much there" are different claims, so they are + // different tests. The winning set is listed explicitly rather than derived from + // `max_ratio < 1.0`: a budget loosened past 1.0 during a regression would otherwise drop out + // of this test silently, which is exactly when it should fail. + const WINS: &[&str] = &[ + "gradient_rgb8", + "photo_rgb8", + "grey_as_rgb8", + "flat_rgba8", + "sprite_rgba8", + ]; + for budget in BUDGETS.iter().filter(|b| WINS.contains(&b.name)) { + let (samples, channels) = pixels(budget.name); + let ours = gamut_best(&samples, channels); + let theirs = libpng9(&samples, channels); + assert!( + ours.len() < theirs.len(), + "{}: claims a structural win but measured {} vs {}", + budget.name, + ours.len(), + theirs.len(), + ); + } +} + +#[test] +fn the_deflate_stage_accounts_for_the_residual_gap() { + // The attribution test, and the reason `deconstruct` is a dependency of this file. Where both + // encoders land on the same colour type and depth, the filtered stream is identical by + // construction, so the ratio of the *compressed* streams isolates DEFLATE from filtering and + // from the colour-type choice. Only the rows where no reduction applies can say this. + for name in ["gradient_rgb8", "photo_rgb8"] { + let (samples, channels) = pixels(name); + let ours = gamut_best(&samples, channels); + let theirs = libpng9(&samples, channels); + let (a, b) = ( + deconstruct(&ours).expect("gamut output deconstructs"), + deconstruct(&theirs).expect("libpng output deconstructs"), + ); + + assert_eq!( + (a.header.color_type, a.header.bit_depth), + (b.header.color_type, b.header.bit_depth), + "{name}: attribution only holds when both land on the same representation", + ); + assert_eq!( + a.filtered_len, b.filtered_len, + "{name}: same representation means an identical filtered stream length", + ); + assert!( + a.idat_compressed <= b.idat_compressed, + "{name}: gamut's DEFLATE stage produced {} bytes against libpng-9's {}", + a.idat_compressed, + b.idat_compressed, + ); + } +} + +#[test] +fn encoded_size_is_deterministic() { + // Without this the budget table is measuring noise rather than the encoder. + for budget in BUDGETS { + let (samples, channels) = pixels(budget.name); + let first = gamut_best(&samples, channels); + let second = gamut_best(&samples, channels); + assert_eq!(first, second, "{}: encode is not reproducible", budget.name); + } +} From ded5128874fe7721871b90cf673813cfd05c6f3e Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 13:58:36 -0400 Subject: [PATCH 05/54] feat(png): opt-in cleanup of invisible pixel colour At `alpha == 0` the colour channels are invisible by definition, but the source's bytes are still stored and still cost. `with_transparent_cleanup` zeroes them. Off by default, and deliberately separate from `with_auto_reduce`: every other reduction in this crate is exactly reversible, and this one is only reversible in what you can see. It pays three compounding ways -- transparent pixels become identical so a run filters to zeros; `analyze8` keys its palette on the whole RGBA quad, so invisible pixels that differ only in unseen colour stop costing an entry each; and it is the precondition for a tRNS colour key, which needs one colour to stand for "transparent". One constant, not the neighbouring pixel's colour, and that was measured rather than assumed. Inheriting the predecessor flattens a run just as well, but leaves every invisible pixel a distinct RGBA quad, so the palette and tRNS benefits both vanish: on a fixture alternating visible and invisible pixels it collapsed nothing and saved exactly zero bytes (378 vs 378). Zeroing collapses them to one entry. Two halves to the claim, so two techniques. That nothing visible changes is differential: libpng decodes both files and every pixel with non-zero alpha must be byte-identical, with alpha itself identical everywhere. That it pays is a size assertion against the same image encoded without it. Measured, and the interaction is worth stating plainly -- on the 256x256 sprite this makes the file *larger*: side clean total colour type IDAT 64 false 859 TruecolorAlpha 802 64 true 817 Indexed/8 549 128 false 1669 TruecolorAlpha 1612 128 true 1925 Indexed/8 1477 256 false 3729 TruecolorAlpha 3672 256 true 4589 Indexed/8 3781 The cleanup is not what regresses: its IDAT is smaller at every size. What happens is that collapsing the invisible colours drops the image under the 256-colour cliff, so `analyze8` now offers a palette -- and the raw-size cost model then picks it, exactly as it wrongly picks it for `palette64_rgba8` in the previous commit. Same defect, second independent witness, and cleaning makes it reachable on more images. The next commit fixes the model; this one would have been a regression shipped alone. Refs #224 --- crates/gamut-png/benches/encode.rs | 24 +++- crates/gamut-png/src/encoder.rs | 56 +++++++- crates/gamut-png/src/reduce.rs | 43 +++++++ crates/gamut-png/tests/size_contract.rs | 1 + crates/gamut-png/tests/transparent_cleanup.rs | 121 ++++++++++++++++++ 5 files changed, 236 insertions(+), 9 deletions(-) create mode 100644 crates/gamut-png/tests/transparent_cleanup.rs diff --git a/crates/gamut-png/benches/encode.rs b/crates/gamut-png/benches/encode.rs index 2ac31b70..372531eb 100644 --- a/crates/gamut-png/benches/encode.rs +++ b/crates/gamut-png/benches/encode.rs @@ -79,10 +79,22 @@ impl Case { /// Encodes with gamut at the given knobs. fn gamut(&self, level: Level, filter: FilterStrategy, auto_reduce: bool) -> Vec { + self.gamut_with(level, filter, auto_reduce, false) + } + + /// As [`Self::gamut`], with the opt-in transparent-colour cleanup as well. + fn gamut_with( + &self, + level: Level, + filter: FilterStrategy, + auto_reduce: bool, + cleanup: bool, + ) -> Vec { let encoder = PngEncoder::new() .with_compression(level) .with_filter(filter) - .with_auto_reduce(auto_reduce); + .with_auto_reduce(auto_reduce) + .with_transparent_cleanup(cleanup); let dims = Dimensions::new(self.width, self.height).expect("corpus dimensions are valid"); let mut out = Vec::new(); match &self.pixels { @@ -161,25 +173,27 @@ const BEST: (Level, FilterStrategy, bool) = (Level::Best, FilterStrategy::BruteF fn print_size_table() { println!( "\ngamut-png output size, bytes (lower is better); bpp is the whole file over the pixel count:\n\n\ - {:<17} {:>9} {:>9} {:>9} {:>9} {:>9} {:>7} {:>7}", - "input", "raw", "default", "best", "libpng-9", "best/lp9", "bpp", "lp9 bpp" + {:<17} {:>9} {:>9} {:>9} {:>9} {:>9} {:>9} {:>7}", + "input", "raw", "default", "best", "+clean", "libpng-9", "best/lp9", "bpp" ); for case in corpus() { let default = case.gamut(Level::Default, FilterStrategy::MinSumAbs, false); let best = case.gamut(BEST.0, BEST.1, BEST.2); + // The opt-in cleanup is off in every other column; this one shows what it is worth. + let cleaned = case.gamut_with(BEST.0, BEST.1, BEST.2, true); let libpng = case.libpng9(); let delta = (best.len() as f64 / libpng.len().max(1) as f64 - 1.0) * 100.0; let bpp = |bytes: &[u8]| bytes.len() as f64 * 8.0 / f64::from(case.width * case.height); println!( - "{:<17} {:>9} {:>9} {:>9} {:>9} {:>8.1}% {:>7.3} {:>7.3}", + "{:<17} {:>9} {:>9} {:>9} {:>9} {:>9} {:>8.1}% {:>7.3}", case.name, case.raw_len(), default.len(), best.len(), + cleaned.len(), libpng.len(), delta, bpp(&best), - bpp(&libpng), ); } } diff --git a/crates/gamut-png/src/encoder.rs b/crates/gamut-png/src/encoder.rs index 008ab359..7a43e25c 100644 --- a/crates/gamut-png/src/encoder.rs +++ b/crates/gamut-png/src/encoder.rs @@ -39,6 +39,7 @@ pub struct PngEncoder { filter: FilterStrategy, ancillary: Ancillary, auto_reduce: bool, + clean_transparent: bool, backends: Registry, } @@ -59,6 +60,7 @@ impl PngEncoder { filter: FilterStrategy::MinSumAbs, ancillary: Ancillary::default(), auto_reduce: false, + clean_transparent: false, backends: Registry::default(), } } @@ -122,6 +124,24 @@ impl PngEncoder { self } + /// Rewrites the colour channels of fully transparent pixels before encoding, so runs of + /// them compress instead of carrying whatever the source left there. + /// + /// Nothing a decoder renders changes -- at `alpha == 0` the colour channels are invisible by + /// definition -- but the stored samples do, so this is **not** lossless in the strict byte + /// sense [`with_auto_reduce`](Self::with_auto_reduce) keeps. That is why it is off by + /// default and separate from it: this crate's other reductions are exactly reversible, and + /// this one is only reversible in what you can see. + /// + /// Worth enabling for sprites, icons and UI assets, where invisible colour noise is common + /// and can cost real bytes. No effect on an image with no fully transparent pixel, or on a + /// layout with no alpha channel. + #[must_use] + pub fn with_transparent_cleanup(mut self, enabled: bool) -> Self { + self.clean_transparent = enabled; + self + } + /// Enables automatic lossless reduction of any [`EncodeImage`] input to a smaller encoding /// when it does not change any pixel: greyscale (at the smallest exactly-representable bit /// depth), palette, alpha-channel drop, and 16→8 demotion when every sample's high and low @@ -341,6 +361,14 @@ impl PngEncoder { ) } + /// The cleaned samples, or `None` to use the caller's buffer unchanged — either because the + /// knob is off or because the image has no fully transparent pixel. + fn cleaned_samples(&self, samples: &[u8], channels: usize) -> Option> { + self.clean_transparent + .then(|| reduce::clean_transparent(samples, channels)) + .flatten() + } + /// Encodes a 16-bit-per-sample image, serialising samples big-endian (PNG's network byte order). fn encode_16bit>( &self, @@ -573,22 +601,42 @@ impl EncodeImage for PngEncoder { } impl EncodeImage for PngEncoder { fn encode_image(&self, image: ImageRef<'_, Rgba8>, out: &mut Vec) -> Result { + let cleaned = self.cleaned_samples(image.as_samples(), 4); + let samples = cleaned.as_deref().unwrap_or_else(|| image.as_samples()); if self.auto_reduce - && let Some(reduced) = reduce::analyze8(image.as_samples(), 4) + && let Some(reduced) = reduce::analyze8(samples, 4) { return self.write_reduced(image.dimensions(), reduced, out); } - self.encode_8bit(image, ColorType::TruecolorAlpha, out) + let dims = image.dimensions(); + self.write_png( + (dims.width, dims.height), + samples, + ColorType::TruecolorAlpha, + 8, + |_| {}, + out, + ) } } impl EncodeImage for PngEncoder { fn encode_image(&self, image: ImageRef<'_, GrayAlpha8>, out: &mut Vec) -> Result { + let cleaned = self.cleaned_samples(image.as_samples(), 2); + let samples = cleaned.as_deref().unwrap_or_else(|| image.as_samples()); if self.auto_reduce - && let Some(reduced) = reduce::analyze8(image.as_samples(), 2) + && let Some(reduced) = reduce::analyze8(samples, 2) { return self.write_reduced(image.dimensions(), reduced, out); } - self.encode_8bit(image, ColorType::GrayscaleAlpha, out) + let dims = image.dimensions(); + self.write_png( + (dims.width, dims.height), + samples, + ColorType::GrayscaleAlpha, + 8, + |_| {}, + out, + ) } } impl EncodeImage for PngEncoder { diff --git a/crates/gamut-png/src/reduce.rs b/crates/gamut-png/src/reduce.rs index 283d4791..308dd927 100644 --- a/crates/gamut-png/src/reduce.rs +++ b/crates/gamut-png/src/reduce.rs @@ -58,6 +58,49 @@ pub(crate) fn index_bit_depth(palette_len: usize) -> u8 { } } +/// Zeroes the colour channels of every fully transparent pixel, leaving alpha alone. Returns +/// `None` when the image has no fully transparent pixel to clean. +/// +/// Nothing a decoder renders changes: at `alpha == 0` the colour channels are invisible by +/// definition. What changes is how well the image *compresses*, in three compounding ways: +/// +/// 1. Transparent pixels all become identical, so `Sub` and `Paeth` filter a run of them to +/// zeros instead of to whatever noise the source happened to carry. +/// 2. [`analyze8`] keys its palette on the whole RGBA quad, so two invisible pixels that differ +/// only in their unseen colour cost two palette entries today. This collapses every +/// transparent pixel to a single entry. +/// 3. It is the precondition for a `tRNS` colour key, which needs one colour to stand for +/// "transparent". +/// +/// One constant, not the neighbouring pixel's colour, and that choice was measured rather than +/// assumed. Inheriting the predecessor flattens a *run* just as well, but leaves every invisible +/// pixel a distinct RGBA quad, so (2) and (3) both fail: on an image alternating visible and +/// invisible pixels it collapsed nothing at all and saved zero bytes. +/// +/// This is *not* lossless in the strict byte sense the rest of this module keeps -- the stored +/// samples change -- which is why it is opt-in via +/// [`PngEncoder::with_transparent_cleanup`](crate::PngEncoder::with_transparent_cleanup) and off +/// by default. `channels` must be 2 (grey + alpha) or 4 (RGBA); layouts without an alpha channel +/// have nothing to clean and return `None`. +pub(crate) fn clean_transparent(pixels: &[u8], channels: usize) -> Option> { + debug_assert!((1..=4).contains(&channels)); + if !channels.is_multiple_of(2) { + return None; // no alpha channel + } + let colour = channels - 1; // colour channels are everything before alpha + if !pixels.chunks_exact(channels).any(|px| px[colour] == 0) { + return None; + } + + let mut out = pixels.to_vec(); + for px in out.chunks_exact_mut(channels) { + if px[colour] == 0 { + px[..colour].fill(0); + } + } + Some(out) +} + /// The RGBA quad a pixel of any supported layout presents: grey replicates into R=G=B, and layouts /// without an alpha channel (the odd channel counts) are opaque. fn pixel_key(px: &[u8], channels: usize) -> [u8; 4] { diff --git a/crates/gamut-png/tests/size_contract.rs b/crates/gamut-png/tests/size_contract.rs index 8f598499..72f816e6 100644 --- a/crates/gamut-png/tests/size_contract.rs +++ b/crates/gamut-png/tests/size_contract.rs @@ -96,6 +96,7 @@ const BUDGETS: &[Budget] = &[ The estimate sees 16 664 against 65 536 and picks palette by 4x; the crossover is \ near 160x160. The budget records the loss; a cost model that weighs incompressible \ overhead against compressible pixels is what tightens it.", + }, ]; /// Half the bench's side, so this file stays fast enough for the coverage and mutation lanes. diff --git a/crates/gamut-png/tests/transparent_cleanup.rs b/crates/gamut-png/tests/transparent_cleanup.rs new file mode 100644 index 00000000..ba8ac66c --- /dev/null +++ b/crates/gamut-png/tests/transparent_cleanup.rs @@ -0,0 +1,121 @@ +//! `PngEncoder::with_transparent_cleanup` (issue #224): rewriting the colour of invisible pixels. +//! +//! The claim has two halves and they need different techniques. That nothing *visible* changes is +//! a differential claim, checked by decoding with libpng and comparing every pixel a viewer could +//! see. That it actually pays is a size claim, checked against the same image encoded without it. +//! +//! Both halves matter: a cleanup that changed a visible pixel would be a correctness bug, and one +//! that saved no bytes would be churn. + +mod common; + +use gamut_core::{Dimensions, EncodeImage, ImageRef, Rgba8}; +use gamut_png::{FilterStrategy, Level, PngEncoder}; + +const SIDE: u32 = 64; + +fn encode(samples: &[u8], cleanup: bool, auto_reduce: bool) -> Vec { + let dims = Dimensions::new(SIDE, SIDE).expect("valid dimensions"); + let image = ImageRef::::new(samples, dims).expect("buffer matches dimensions"); + let mut out = Vec::new(); + PngEncoder::new() + .with_compression(Level::Best) + .with_filter(FilterStrategy::BruteForce) + .with_auto_reduce(auto_reduce) + .with_transparent_cleanup(cleanup) + .encode_image(image, &mut out) + .expect("encode"); + out +} + +#[test] +fn every_visible_pixel_survives_cleanup_unchanged() { + // libpng decodes both files; every pixel with a non-zero alpha must be byte-identical, and + // every alpha must be identical everywhere. Only the colour under alpha == 0 may differ. + let src = common::corpus::sprite_rgba(SIDE); + let plain = libpng_oracle::decode_rgba8(&encode(&src, false, false)).2; + let cleaned = libpng_oracle::decode_rgba8(&encode(&src, true, false)).2; + + assert_eq!(plain.len(), cleaned.len()); + let mut invisible_changed = 0usize; + let (plain_px, _) = plain.as_chunks::<4>(); + let (clean_px, _) = cleaned.as_chunks::<4>(); + for (i, (a, b)) in plain_px.iter().zip(clean_px).enumerate() { + assert_eq!(a[3], b[3], "pixel {i}: alpha must never change"); + if a[3] == 0 { + if a[..3] != b[..3] { + invisible_changed += 1; + } + } else { + assert_eq!(a, b, "pixel {i} is visible and must be byte-identical"); + } + } + assert!( + invisible_changed > 0, + "the fixture must actually exercise the cleanup" + ); +} + +#[test] +fn cleanup_shrinks_an_image_with_invisible_colour_noise() { + let src = common::corpus::sprite_rgba(SIDE); + let plain = encode(&src, false, false); + let cleaned = encode(&src, true, false); + assert!( + cleaned.len() < plain.len(), + "cleanup should pay on a sprite: {} vs {}", + cleaned.len(), + plain.len() + ); +} + +#[test] +fn cleanup_is_inert_on_a_fully_opaque_image() { + // No fully transparent pixel means nothing to rewrite, and the output must be byte-identical + // rather than merely the same size — this is what pins that the pass is a no-op, not a + // re-encode that happens to land on the same length. + let src = common::corpus::flat_rgba(SIDE); + assert_eq!(encode(&src, false, true), encode(&src, true, true)); +} + +#[test] +fn cleanup_collapses_invisible_pixels_into_one_palette_entry() { + // The compounding effect: `analyze8` keys its palette on the whole RGBA quad, so invisible + // pixels that differ only in unseen colour cost an entry each. This fixture has 64 visible + // colours and 64 *distinct* invisible ones, which is over the 256-entry cliff only in the + // sense that it doubles the table; cleaning collapses the invisible half. + let mut src = vec![0u8; (SIDE * SIDE * 4) as usize]; + for (i, px) in src.as_chunks_mut::<4>().0.iter_mut().enumerate() { + let v = (i % 64) as u8; + if i % 2 == 0 { + px.copy_from_slice(&[v, v, v, 255]); + } else { + // Invisible, and every one a different colour. + px.copy_from_slice(&[v.wrapping_mul(3), v.wrapping_add(7), 200 - v, 0]); + } + } + let plain = encode(&src, false, true); + let cleaned = encode(&src, true, true); + assert!( + cleaned.len() < plain.len(), + "collapsing the invisible half should shrink the palette: {} vs {}", + cleaned.len(), + plain.len() + ); +} + +#[test] +fn cleanup_is_off_by_default() { + // The default must stay byte-for-byte lossless, so an encoder that was never asked for + // cleanup must produce exactly what it produced before this feature existed. + let src = common::corpus::sprite_rgba(SIDE); + let dims = Dimensions::new(SIDE, SIDE).expect("valid dimensions"); + let image = ImageRef::::new(&src, dims).expect("buffer matches dimensions"); + let mut default_out = Vec::new(); + PngEncoder::new() + .with_compression(Level::Best) + .with_filter(FilterStrategy::BruteForce) + .encode_image(image, &mut default_out) + .expect("encode"); + assert_eq!(default_out, encode(&src, false, false)); +} From 6b31ab90645f7876ddcf3cd7e8b01a171c7417ba Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 14:06:58 -0400 Subject: [PATCH 06/54] fix(png): keep the palette only when it is actually smaller `reduce::analyze8` chooses by comparing **raw** sizes, and raw size does not predict compressed size when one candidate's bytes are incompressible and the other's are not. A palette carries PLTE (and often tRNS) that DEFLATE cannot touch, while the pixels it replaces may compress by two orders of magnitude. Two independent measurements from the previous commits: * `palette64_rgba8` at 128x128: PLTE + tRNS is a flat 273 bytes, the indexed pixel data compresses to 121, and the RGBA alternative compresses to 405 in total. The estimate sees 16 664 against 65 536 and picks the palette by 4x. Finished files: 451 against libpng-9's 405 -- the only corpus row where gamut lost. * The sprite, once transparent-colour cleanup collapses its invisible pixels under the 256-colour cliff, becomes palettisable and is then chosen at every size: 817 vs 859 at 64x64, but 1925 vs 1669 at 128 and 4589 vs 3729 at 256. Same defect, and cleaning made it reachable on more images. Rather than guess a correction factor, `write_reduced_or_native` encodes both candidates and keeps the smaller. That is exactly what `FilterStrategy::BruteForce` already does for filters, it needs no tuned constant, and it cannot be worse than either candidate alone. A tie keeps the palette, which decodes with less work. Only palette reductions pay for the second encode. Greyscale, alpha-drop and 16->8 demotion add no chunks, so for them the raw comparison is already sound and the function returns immediately. Measured after: row before after palette64_rgba8 @128 451 390 (libpng-9: 405, now a win) sprite_rgba8 +clean @256 4589 2619 (uncleaned best: 3729) The sprite is the striking one: cleanup was a 23% regression and is now a 30% improvement, because the race stops the analysis's mistake from landing. Two oracle tests changed, and the reason is worth stating rather than burying. Both pinned a *colour type* as a proxy for "a reduction happened", and the race decouples those: the analysis still offers a palette, the encoder now declines it when it would cost bytes. On 32x32 fixtures with a handful of repeating colours the unreduced stream genuinely wins, so the old expectations were asserting the defect. They now assert the contract that matters -- the pixels survive, and the smaller file is kept -- and a new `a_palette_is_chosen_when_it_actually_wins` covers the other side of the race at 192x192, where the fixed cost is amortised. Without it the palette encoding path would only ever be exercised where it loses. The analysis contract itself stays pinned by `reduce`'s own unit tests, which is where it belongs. Refs #224 --- crates/gamut-png/src/encoder.rs | 121 ++++++++++++++++++++++-- crates/gamut-png/tests/oracle.rs | 70 +++++++++++++- crates/gamut-png/tests/size_contract.rs | 23 ++--- 3 files changed, 185 insertions(+), 29 deletions(-) diff --git a/crates/gamut-png/src/encoder.rs b/crates/gamut-png/src/encoder.rs index 7a43e25c..5173fd33 100644 --- a/crates/gamut-png/src/encoder.rs +++ b/crates/gamut-png/src/encoder.rs @@ -468,6 +468,49 @@ impl PngEncoder { } } + /// Writes `reduced`, unless it is a palette encoding that turns out *larger* than encoding + /// the image untouched — in which case the untouched one wins. + /// + /// [`reduce::analyze8`] chooses by comparing **raw** sizes, and raw size does not predict + /// compressed size when one candidate's bytes are incompressible and the other's are not. A + /// palette carries a `PLTE` (and often `tRNS`) chunk that DEFLATE cannot touch, while the + /// pixels it replaces may compress by two orders of magnitude. On a 128x128 image with 64 + /// colours the estimate sees 16 664 bytes against 65 536 and picks the palette by 4x — and + /// the finished file is 451 bytes against 405. The crossover sits near 160x160, so the + /// estimate is right on large images and wrong on small ones. + /// + /// Rather than guess a correction factor, the two candidates are encoded and the smaller + /// kept. That is exactly what [`FilterStrategy::BruteForce`] already does for filters, it + /// needs no tuned constant, and it cannot be worse than either candidate alone. A tie keeps + /// the palette, which decodes with less work. + /// + /// Only palette reductions pay for the second encode. Greyscale, alpha-drop and 16→8 + /// demotion add no chunks at all, so for them the raw comparison is sound and this returns + /// immediately. + fn write_reduced_or_native( + &self, + dims: Dimensions, + reduced: Reduced, + native: impl FnOnce(&mut Vec) -> Result, + out: &mut Vec, + ) -> Result { + if !matches!(reduced, Reduced::Indexed { .. }) { + return self.write_reduced(dims, reduced, out); + } + let mut palette_encoding = Vec::new(); + self.write_reduced(dims, reduced, &mut palette_encoding)?; + let mut native_encoding = Vec::new(); + native(&mut native_encoding)?; + + let winner = if native_encoding.len() < palette_encoding.len() { + native_encoding + } else { + palette_encoding + }; + out.extend_from_slice(&winner); + Ok(winner.len()) + } + /// Writes a reduced encoding chosen by [`reduce::analyze8`] / [`reduce::analyze16`]. fn write_reduced( &self, @@ -564,7 +607,12 @@ impl EncodeImage for PngEncoder { if self.auto_reduce && let Some(reduced) = reduce::analyze8(image.as_samples(), 1) { - return self.write_reduced(image.dimensions(), reduced, out); + return self.write_reduced_or_native( + image.dimensions(), + reduced, + |o| self.encode_8bit(image, ColorType::Grayscale, o), + out, + ); } self.encode_8bit(image, ColorType::Grayscale, out) } @@ -594,7 +642,12 @@ impl EncodeImage for PngEncoder { if self.auto_reduce && let Some(reduced) = reduce::analyze8(image.as_samples(), 3) { - return self.write_reduced(image.dimensions(), reduced, out); + return self.write_reduced_or_native( + image.dimensions(), + reduced, + |o| self.encode_8bit(image, ColorType::Truecolor, o), + out, + ); } self.encode_8bit(image, ColorType::Truecolor, out) } @@ -603,12 +656,26 @@ impl EncodeImage for PngEncoder { fn encode_image(&self, image: ImageRef<'_, Rgba8>, out: &mut Vec) -> Result { let cleaned = self.cleaned_samples(image.as_samples(), 4); let samples = cleaned.as_deref().unwrap_or_else(|| image.as_samples()); + let dims = image.dimensions(); if self.auto_reduce && let Some(reduced) = reduce::analyze8(samples, 4) { - return self.write_reduced(image.dimensions(), reduced, out); + return self.write_reduced_or_native( + dims, + reduced, + |o| { + self.write_png( + (dims.width, dims.height), + samples, + ColorType::TruecolorAlpha, + 8, + |_| {}, + o, + ) + }, + out, + ); } - let dims = image.dimensions(); self.write_png( (dims.width, dims.height), samples, @@ -623,12 +690,26 @@ impl EncodeImage for PngEncoder { fn encode_image(&self, image: ImageRef<'_, GrayAlpha8>, out: &mut Vec) -> Result { let cleaned = self.cleaned_samples(image.as_samples(), 2); let samples = cleaned.as_deref().unwrap_or_else(|| image.as_samples()); + let dims = image.dimensions(); if self.auto_reduce && let Some(reduced) = reduce::analyze8(samples, 2) { - return self.write_reduced(image.dimensions(), reduced, out); + return self.write_reduced_or_native( + dims, + reduced, + |o| { + self.write_png( + (dims.width, dims.height), + samples, + ColorType::GrayscaleAlpha, + 8, + |_| {}, + o, + ) + }, + out, + ); } - let dims = image.dimensions(); self.write_png( (dims.width, dims.height), samples, @@ -644,7 +725,12 @@ impl EncodeImage for PngEncoder { if self.auto_reduce && let Some(reduced) = reduce::analyze16(image.as_samples(), 1) { - return self.write_reduced(image.dimensions(), reduced, out); + return self.write_reduced_or_native( + image.dimensions(), + reduced, + |o| self.encode_16bit(image, ColorType::Grayscale, o), + out, + ); } self.encode_16bit(image, ColorType::Grayscale, out) } @@ -654,7 +740,12 @@ impl EncodeImage for PngEncoder { if self.auto_reduce && let Some(reduced) = reduce::analyze16(image.as_samples(), 3) { - return self.write_reduced(image.dimensions(), reduced, out); + return self.write_reduced_or_native( + image.dimensions(), + reduced, + |o| self.encode_16bit(image, ColorType::Truecolor, o), + out, + ); } self.encode_16bit(image, ColorType::Truecolor, out) } @@ -664,7 +755,12 @@ impl EncodeImage for PngEncoder { if self.auto_reduce && let Some(reduced) = reduce::analyze16(image.as_samples(), 4) { - return self.write_reduced(image.dimensions(), reduced, out); + return self.write_reduced_or_native( + image.dimensions(), + reduced, + |o| self.encode_16bit(image, ColorType::TruecolorAlpha, o), + out, + ); } self.encode_16bit(image, ColorType::TruecolorAlpha, out) } @@ -674,7 +770,12 @@ impl EncodeImage for PngEncoder { if self.auto_reduce && let Some(reduced) = reduce::analyze16(image.as_samples(), 2) { - return self.write_reduced(image.dimensions(), reduced, out); + return self.write_reduced_or_native( + image.dimensions(), + reduced, + |o| self.encode_16bit(image, ColorType::GrayscaleAlpha, o), + out, + ); } self.encode_16bit(image, ColorType::GrayscaleAlpha, out) } diff --git a/crates/gamut-png/tests/oracle.rs b/crates/gamut-png/tests/oracle.rs index 218914c3..34653c40 100644 --- a/crates/gamut-png/tests/oracle.rs +++ b/crates/gamut-png/tests/oracle.rs @@ -406,9 +406,14 @@ fn auto_reduce_cases() -> (Dimensions, [AutoReduceCase; 3]) { expected_type: libpng_oracle::COLOR_GRAY, }, AutoReduceCase { + // Three colours repeating with period 3: DEFLATE squeezes the RGBA stream to + // less than the palette encoding's PLTE + tRNS + framing costs on its own, so + // `write_reduced_or_native` keeps the unreduced form. That is the smaller file, + // which is the contract; `a_palette_is_chosen_when_it_actually_wins` covers the + // other side of that race, and `reduce`'s own unit tests pin the analysis. name: "palette", rgba: palette, - expected_type: libpng_oracle::COLOR_PALETTE, + expected_type: libpng_oracle::COLOR_RGBA, }, AutoReduceCase { name: "opaque", @@ -446,6 +451,54 @@ fn auto_reduce_picks_the_colour_type_the_pixels_allow() { } } +/// The palette side of `write_reduced_or_native`'s race. +/// +/// A palette costs a flat `PLTE` (+ `tRNS`) that DEFLATE cannot compress, so whether it wins is +/// size-dependent: the fixed cost has to be amortised over enough pixels. At 32x32 it is not, and +/// the cases above keep the unreduced form; at 192x192 with the same colour count it is, and the +/// encoder must take the palette. Without this test the palette encoding path would only ever be +/// exercised where it loses. +#[test] +fn a_palette_is_chosen_when_it_actually_wins() { + let (w, h) = (192u32, 192u32); + let dims = Dimensions::new(w, h).unwrap(); + // 64 distinct colours in 8x8 blocks: too many for RGBA to compress away, few enough to index. + let mut src = Vec::with_capacity((w * h * 4) as usize); + for y in 0..h { + for x in 0..w { + let idx = ((x / 8 + y / 8 * 8) % 64) as u8; + src.extend_from_slice(&[ + idx.wrapping_mul(4), + idx.wrapping_mul(9), + 255 - idx.wrapping_mul(3), + 255, + ]); + } + } + + let reduced = encode_auto_reduced(&src, dims); + assert_eq!( + libpng_oracle::decode(&reduced).color_type, + libpng_oracle::COLOR_PALETTE, + "the palette wins once its fixed cost is amortised" + ); + + let mut plain = Vec::new(); + PngEncoder::new() + .with_compression(Level::Best) + .encode_image(ImageRef::::new(&src, dims).unwrap(), &mut plain) + .expect("encode"); + assert!( + reduced.len() < plain.len(), + "and it is smaller: {} vs {}", + reduced.len(), + plain.len() + ); + + let (_, _, rgba) = libpng_oracle::decode_rgba8(&reduced); + assert_eq!(rgba, src, "the palette resolves losslessly"); +} + #[test] fn auto_reduce_is_lossless() { // The claim that makes the reduction safe to enable at all: whatever colour type it chose, @@ -516,18 +569,25 @@ fn extended_auto_reduce_covers_grey_and_sixteen_bit_inputs() { // packed one. The depth/pixel checks above pin the contract that matters. } - // Low-cardinality grey off the scale grid -> a grey palette at 2 bits. + // Low-cardinality grey off the scale grid. `reduce::analyze8` offers a 2-bit grey palette, + // but on a fixture this small and this regular the plain 8-bit grey stream compresses to less + // than the palette's PLTE and framing, so `write_reduced_or_native` keeps grey. What matters + // here is that the pixels survive whichever wins. let off_grid: Vec = (0..n).map(|i| [5u8, 9, 200][i % 3]).collect(); let mut png = Vec::new(); encoder() .encode_image(ImageRef::::new(&off_grid, dims).unwrap(), &mut png) .expect("encode"); let dec = libpng_oracle::decode(&png); - assert_eq!(dec.color_type, libpng_oracle::COLOR_PALETTE); - assert_eq!(dec.bit_depth, 2); + assert!( + dec.color_type == libpng_oracle::COLOR_GRAY + || dec.color_type == libpng_oracle::COLOR_PALETTE, + "off-grid grey stays grey or becomes a grey palette, got {}", + dec.color_type + ); let (_, _, rgba) = libpng_oracle::decode_rgba8(&png); let expected: Vec = off_grid.iter().flat_map(|&v| [v, v, v, 255]).collect(); - assert_eq!(rgba, expected, "grey palette resolves losslessly"); + assert_eq!(rgba, expected, "off-grid grey resolves losslessly"); // GrayAlpha8 with an all-opaque alpha channel -> plain 8-bit grey. let ga: Vec = (0..n).flat_map(|i| [(i % 89) as u8, 255]).collect(); diff --git a/crates/gamut-png/tests/size_contract.rs b/crates/gamut-png/tests/size_contract.rs index 72f816e6..0a774b96 100644 --- a/crates/gamut-png/tests/size_contract.rs +++ b/crates/gamut-png/tests/size_contract.rs @@ -82,20 +82,14 @@ const BUDGETS: &[Budget] = &[ }, Budget { name: "palette64_rgba8", - max_ratio: 1.15, - measured: 1.114, - // The one row where gamut is *larger* than libpng, and the budget says so rather than - // hiding it. A real defect the measurement found, filed separately. - why: "gamut auto-palettises (64 colours over two alpha levels); libpng-9 writes full \ - RGBA. At 256x256 that wins by 35%, but at 128x128 it LOSES by 11%. Not because \ - `reduce::analyze8` ignores the palette chunks -- it does count them -- but because \ - it compares *raw* sizes, and raw size does not predict compressed size when one \ - candidate's bytes are incompressible and the other's are not. Measured: PLTE + \ - tRNS is a flat 273 bytes that DEFLATE cannot touch, while the indexed pixel data \ - compresses to 121 and the RGBA alternative libpng writes compresses to 405 total. \ - The estimate sees 16 664 against 65 536 and picks palette by 4x; the crossover is \ - near 160x160. The budget records the loss; a cost model that weighs incompressible \ - overhead against compressible pixels is what tightens it.", + max_ratio: 1.00, + measured: 0.963, + why: "64 colours over two alpha levels. The palette encoding wins outright at 256x256 \ + but loses at this size, because PLTE + tRNS is a flat 273 incompressible bytes \ + against pixels that compress ~160x; `write_reduced_or_native` encodes both and \ + keeps the smaller, so the row measures whichever is actually better here rather \ + than whichever the raw-size estimate preferred. Budgeted at 1.00 rather than \ + tighter precisely because which candidate wins is size-dependent.", }, ]; @@ -188,6 +182,7 @@ fn gamut_beats_libpng9_where_it_claims_to() { "grey_as_rgb8", "flat_rgba8", "sprite_rgba8", + "palette64_rgba8", ]; for budget in BUDGETS.iter().filter(|b| WINS.contains(&b.name)) { let (samples, channels) = pixels(budget.name); From 85beb2f0dd3297a4ea6a3cac007d173b4b8ef697 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 14:35:59 -0400 Subject: [PATCH 07/54] perf(png): accelerate CRC-32 and the scanline filter loops Both hot loops the new benchmark exposed, neither needing any `unsafe` in gamut. Output is byte-identical: every row of the size table is unchanged, and the oracle, determinism and size-contract suites all still pass. This buys time, not bytes. before after crc32 420.8 MB/s 8.996 GB/s 21x filter_image None 497.9 MB/s 16.26 GB/s 33x filter_image Paeth 277.1 MB/s 1.202 GB/s 4.3x filter_image MSA 46.7 MB/s 265.8 MB/s 5.7x choose_min_sum_abs 68.0 MB/s 308.4 MB/s 4.5x CRC-32 moves to `crc32fast`, which dispatches to PCLMULQDQ/AVX-512 on x86-64 and the `crc32` instructions on aarch64, with a table fallback elsewhere including wasm32. Its `unsafe` stays inside that crate; gamut-png remains 100% safe Rust, which is why this needed no policy change. The two existing unit tests stay exactly as they were, now as a drift guard: they pin the polynomial this module's doc claims, so a backend computing a different CRC-32 variant fails here rather than silently producing files no decoder accepts. The filter loops needed no dependency at all. Three structural pessimisations were blocking the vectoriser, and removing them is most of the win: * The `i >= bpp` test choosing between a real left-neighbour and an implicit zero is loop-invariant. The row now splits into a `bpp`-long prologue where `a` and `c` are zero and a body where they are not. That collapses Sub to a copy in the prologue and, less obviously, Paeth to Up, because `paeth(0, b, 0) == b` for every `b` -- at `b == 0` all three distances tie and the spec's order picks `a`, which is also zero. * The body reads five equal-length subslices, so the bounds checks fold away instead of being re-proved per index. * The filter is matched once outside the loop instead of once per byte, and `out` is sized once instead of a capacity check per `push`. Separately, `MinSumAbs` was filtering each scanline **six** times, not five: `choose_min_sum_abs` computed all five candidates, returned only which one won, and `filter_image` then recomputed exactly those bytes. It now hands back the winning buffer, trading a `memcpy` per improvement for a full filter pass per row. `unfilter_row` is deliberately untouched. Forward filtering has no serial dependency, so all five kernels vectorise; reconstruction reads `row[i - bpp]` after writing it, so only `Up` would benefit and this is an encoder-first crate. Refs #224 --- Cargo.lock | 3 + crates/gamut-png/Cargo.toml | 5 ++ crates/gamut-png/benches/encode.rs | 6 +- crates/gamut-png/src/crc32.rs | 49 ++++-------- crates/gamut-png/src/filter.rs | 122 +++++++++++++++++++++++------ 5 files changed, 124 insertions(+), 61 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fcdd8e15..cd522af1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -833,9 +833,12 @@ dependencies = [ name = "gamut-png" version = "0.1.0" dependencies = [ + "crc32fast", + "divan", "gamut-codec-abi", "gamut-core", "gamut-deflate", + "gamut-png", "libpng-oracle", "miniz_oxide", ] diff --git a/crates/gamut-png/Cargo.toml b/crates/gamut-png/Cargo.toml index 640602d3..bf09549b 100644 --- a/crates/gamut-png/Cargo.toml +++ b/crates/gamut-png/Cargo.toml @@ -34,6 +34,11 @@ gamut-deflate.workspace = true # is deliberately encoder-only, and its docs bless miniz_oxide as the decode-side inflate (the same # choice gamut-dng made); revisiting an in-house inflater is tracked by issue #196. miniz_oxide = "0.8" +# CRC-32 (ISO-HDLC) for every chunk, IDAT included, so it is on the critical path of every encode. +# Hardware-accelerated (x86-64 PCLMULQDQ/AVX-512, aarch64 `crc32`) with a table fallback elsewhere, +# including wasm32. MIT/Apache-2.0, pure Rust, and it keeps its `unsafe` to itself -- gamut-png +# stays 100% safe Rust. Replaces a hand-written byte-at-a-time table loop; see `src/crc32.rs`. +crc32fast = "1.5" [dev-dependencies] # Differential cross-check oracle: a vendored, statically-linked libpng (built from the diff --git a/crates/gamut-png/benches/encode.rs b/crates/gamut-png/benches/encode.rs index 372531eb..b4abf0db 100644 --- a/crates/gamut-png/benches/encode.rs +++ b/crates/gamut-png/benches/encode.rs @@ -338,9 +338,9 @@ mod stages { let prev: Vec = (0..ROW_BYTES).map(|i| (i * 13 + 5) as u8).collect(); bencher .counter(BytesCount::new(row.len())) - .with_inputs(Vec::new) - .bench_local_refs(|scratch: &mut Vec| { - stages::choose_min_sum_abs(&row, &prev, black_box(bpp), scratch) + .with_inputs(|| (Vec::new(), Vec::new())) + .bench_local_refs(|(scratch, best): &mut (Vec, Vec)| { + stages::choose_min_sum_abs(&row, &prev, black_box(bpp), scratch, best) }); } diff --git a/crates/gamut-png/src/crc32.rs b/crates/gamut-png/src/crc32.rs index 6b68e3a4..c7cc2aad 100644 --- a/crates/gamut-png/src/crc32.rs +++ b/crates/gamut-png/src/crc32.rs @@ -3,34 +3,21 @@ //! This is the reflected CRC-32 with polynomial `0xEDB88320`, initial value all-ones, and a final //! ones-complement, computed over a chunk's **type and data** (not its length). zlib uses Adler-32, //! never this — so CRC-32 lives in the PNG crate, not in `gamut-deflate`. - -/// Precomputed byte-wise CRC table (built at compile time). -const TABLE: [u32; 256] = build_table(); - -const fn build_table() -> [u32; 256] { - let mut table = [0u32; 256]; - let mut n = 0usize; - while n < 256 { - let mut c = n as u32; - let mut k = 0; - while k < 8 { - c = if c & 1 != 0 { - 0xEDB8_8320 ^ (c >> 1) - } else { - c >> 1 - }; - k += 1; - } - table[n] = c; - n += 1; - } - table -} +//! +//! The arithmetic is [`crc32fast`]'s; this module is the PNG-shaped wrapper over it. The tests +//! below stay as a drift guard: they pin the polynomial this file's doc claims, so swapping the +//! backend for one computing a different CRC-32 variant (Castagnoli, say) fails here rather than +//! silently producing files no decoder accepts. /// An incremental CRC-32 accumulator. -pub struct Crc32 { - value: u32, -} +/// +/// Delegates to [`crc32fast`], which dispatches to PCLMULQDQ/AVX-512 on x86-64 and the `crc32` +/// instructions on aarch64, falling back to a table elsewhere (wasm32 included). The `unsafe` +/// that needs is entirely inside that crate; nothing here changes. +/// +/// This runs over every byte of every chunk, IDAT included, so it is on the critical path of +/// every encode. The byte-at-a-time table loop it replaces managed roughly 420 MB/s. +pub struct Crc32(crc32fast::Hasher); impl Crc32 { /// Starts a fresh CRC (register initialised to all ones). @@ -42,21 +29,17 @@ impl Crc32 { reason = "a Default impl here would be dead delegation: uncovered, and unkillable by any test" )] pub fn new() -> Self { - Self { value: 0xFFFF_FFFF } + Self(crc32fast::Hasher::new()) } /// Folds `data` into the running CRC. pub fn update(&mut self, data: &[u8]) { - let mut crc = self.value; - for &b in data { - crc = TABLE[((crc ^ u32::from(b)) & 0xff) as usize] ^ (crc >> 8); - } - self.value = crc; + self.0.update(data); } /// Finalises the CRC (ones-complement of the register). pub fn finish(self) -> u32 { - self.value ^ 0xFFFF_FFFF + self.0.finalize() } } diff --git a/crates/gamut-png/src/filter.rs b/crates/gamut-png/src/filter.rs index bd60e1d9..9bae4b9c 100644 --- a/crates/gamut-png/src/filter.rs +++ b/crates/gamut-png/src/filter.rs @@ -70,21 +70,77 @@ fn paeth(a: u8, b: u8, c: u8) -> u8 { /// Forward-filters one scanline `cur` (with previous raw row `prev`, all zero for the first row) /// into `out` (which is overwritten to `cur.len()` bytes). +/// +/// Structured for the vectoriser rather than for brevity, because this is the encoder's hottest +/// loop -- `MinSumAbs` runs it five times per scanline and `BruteForce` up to ten. Three things +/// matter, and the straightforward version does none of them: +/// +/// * The `i >= bpp` test that picks between a real left-neighbour and an implicit zero is loop +/// invariant, so the row splits into a `bpp`-long prologue where `a` and `c` are zero and a +/// body where they are not. Testing it per byte defeats vectorisation outright. +/// * The body then reads five *equal-length* subslices, which lets the bounds checks fold away +/// instead of being re-proved for every index. +/// * The filter is matched once, outside the loop, so each arm is a straight-line kernel rather +/// than a branch per byte. And `out` is sized once, so there is no capacity check per `push`. fn filter_row(filter: FilterType, cur: &[u8], prev: &[u8], bpp: usize, out: &mut Vec) { + let n = cur.len(); out.clear(); - out.reserve(cur.len()); - for i in 0..cur.len() { - let a = if i >= bpp { cur[i - bpp] } else { 0 }; - let b = prev[i]; - let c = if i >= bpp { prev[i - bpp] } else { 0 }; - let residual = match filter { - FilterType::None => cur[i], - FilterType::Sub => cur[i].wrapping_sub(a), - FilterType::Up => cur[i].wrapping_sub(b), - FilterType::Average => cur[i].wrapping_sub(((u16::from(a) + u16::from(b)) / 2) as u8), - FilterType::Paeth => cur[i].wrapping_sub(paeth(a, b, c)), - }; - out.push(residual); + out.resize(n, 0); + let head = bpp.min(n); + + // Prologue: the first `bpp` bytes have no left neighbour, so `a == c == 0`. That collapses + // Sub to a copy and -- less obviously -- Paeth to Up, because `paeth(0, b, 0) == b` for every + // `b` (at `b == 0` all three distances tie and the spec's order picks `a`, which is also 0). + match filter { + FilterType::None | FilterType::Sub => out[..head].copy_from_slice(&cur[..head]), + FilterType::Up | FilterType::Paeth => { + for (d, (&x, &b)) in out[..head] + .iter_mut() + .zip(cur[..head].iter().zip(&prev[..head])) + { + *d = x.wrapping_sub(b); + } + } + FilterType::Average => { + for (d, (&x, &b)) in out[..head] + .iter_mut() + .zip(cur[..head].iter().zip(&prev[..head])) + { + *d = x.wrapping_sub(b / 2); + } + } + } + + // Body: `x` is the current byte, `a` the byte `bpp` to its left, `b` the byte above, `c` the + // byte above-left. All five slices are the same length by construction. + let m = n - head; + let dst = &mut out[head..]; + let x = &cur[head..]; + let a = &cur[..m]; + let b = &prev[head..]; + let c = &prev[..m]; + match filter { + FilterType::None => dst.copy_from_slice(x), + FilterType::Sub => { + for (d, (&x, &a)) in dst.iter_mut().zip(x.iter().zip(a)) { + *d = x.wrapping_sub(a); + } + } + FilterType::Up => { + for (d, (&x, &b)) in dst.iter_mut().zip(x.iter().zip(b)) { + *d = x.wrapping_sub(b); + } + } + FilterType::Average => { + for (d, ((&x, &a), &b)) in dst.iter_mut().zip(x.iter().zip(a).zip(b)) { + *d = x.wrapping_sub(((u16::from(a) + u16::from(b)) / 2) as u8); + } + } + FilterType::Paeth => { + for (d, (((&x, &a), &b), &c)) in dst.iter_mut().zip(x.iter().zip(a).zip(b).zip(c)) { + *d = x.wrapping_sub(paeth(a, b, c)); + } + } } } @@ -131,31 +187,45 @@ pub fn filter_image( let zero_row = vec![0u8; row_bytes]; let mut prev = zero_row.as_slice(); let mut scratch = Vec::with_capacity(row_bytes); + let mut chosen = Vec::with_capacity(row_bytes); for y in 0..height { let cur = &samples[y * row_bytes..(y + 1) * row_bytes]; - let filter = match strategy { - FilterStrategy::None => FilterType::None, - FilterStrategy::Fixed(f) => f, - // BruteForce is resolved to concrete strategies by the encoder; if it reaches here, fall - // back to the per-scanline heuristic. + match strategy { + // BruteForce is resolved to concrete strategies by the encoder; if it reaches here, + // fall back to the per-scanline heuristic. FilterStrategy::MinSumAbs | FilterStrategy::BruteForce => { - choose_min_sum_abs(cur, prev, bpp, &mut scratch) + let filter = choose_min_sum_abs(cur, prev, bpp, &mut scratch, &mut chosen); + out.push(filter as u8); + out.extend_from_slice(&chosen); } - }; - out.push(filter as u8); - filter_row(filter, cur, prev, bpp, &mut scratch); - out.extend_from_slice(&scratch); + FilterStrategy::None | FilterStrategy::Fixed(_) => { + let filter = match strategy { + FilterStrategy::Fixed(f) => f, + _ => FilterType::None, + }; + out.push(filter as u8); + filter_row(filter, cur, prev, bpp, &mut scratch); + out.extend_from_slice(&scratch); + } + } prev = cur; } out } -/// Picks the filter with the lowest sum-of-absolute-residuals for one scanline. +/// Picks the filter with the lowest sum-of-absolute-residuals for one scanline, leaving that +/// filter's bytes in `best_bytes`. +/// +/// Returning the winning bytes rather than just the winning filter is what makes this five passes +/// over the row instead of six: the caller would otherwise re-run [`filter_row`] for the filter +/// just chosen, having already computed exactly those bytes and thrown them away. Keeping them +/// costs one `memcpy` per improvement, against a full filter pass per scanline. pub fn choose_min_sum_abs( cur: &[u8], prev: &[u8], bpp: usize, scratch: &mut Vec, + best_bytes: &mut Vec, ) -> FilterType { let mut best = FilterType::None; let mut best_score = u64::MAX; @@ -171,6 +241,8 @@ pub fn choose_min_sum_abs( if score < best_score { best_score = score; best = filter; + best_bytes.clear(); + best_bytes.extend_from_slice(scratch); } } best @@ -268,7 +340,7 @@ mod tests { // scores far below None. let row: Vec = (0..30u8).map(|i| i.wrapping_mul(3)).collect(); let prev = vec![0u8; row.len()]; - let chosen = choose_min_sum_abs(&row, &prev, 1, &mut Vec::new()); + let chosen = choose_min_sum_abs(&row, &prev, 1, &mut Vec::new(), &mut Vec::new()); assert_eq!(chosen, FilterType::Sub); } } From fddc749799e1f76e79d1e5928f981053e9859ac7 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 14:40:25 -0400 Subject: [PATCH 08/54] docs: record the gamut-png efficiency baseline and the benchmarking convention `gamut-png`'s STATUS gains an Efficiency section: the size table against libpng-9, the throughput before/after, a per-axis scorecard of the nine things a PNG encoder competes on, and the measured explanation of why the palette choice is now a race rather than an estimate. Every number is reproduced by `cargo bench -p gamut-png` and gated by `tests/size_contract.rs`. Its README and STATUS both claimed "output size is benchmarked against libpng at maximum compression" while no code did either. They now say what is true: measured by the bench, enforced by the contract. `docs/benchmarking.md` is new, and takes an owner for something that had none. `docs/testing.md` disclaimed benchmarks by name, and `docs/README.md` makes anything unlisted there "descriptive, not binding" -- so the conventions every bench in the workspace already follows were binding on nobody. It is normative for where a benchmark lives, what a size or ratio table must record, and where a measured number is kept, and it hands the enforcement question back to `testing.md` explicitly. The rule it turns on: A benchmark reports. A test asserts. Only the test can fail a build. It also records what CI actually does now, which changed under this branch: `mise run lint`'s `--all-targets` compiles every bench on every PR, and the Extended lane's `mise run bench-test` runs each once. Neither gates a number, and the document says why that is still open rather than implying benches are ungated. Both normative documents change here because `docs/README.md` requires it: a `docs/` file that contradicts another is a change to both. Seven follow-ups filed with their measured evidence rather than left as prose: #478 gamut-deflate: 8-byte-at-a-time longest_match -- the dominant cost of every encode in the workspace, safe Rust, byte-identical output #479 gamut-deflate: relax each length at its own nearest distance #480 gamut-png: entropy and bigram heuristics, pruned two-tier trials #481 gamut-png: tRNS colour key for grey and truecolour #482 gamut-png: palette ordering and caller-supplied palette cleanup #483 gamut-png: metadata policy, and the CLI's silent drop #484 gamut-png: parallel filter trials, and a composed effort dial Refs #224 --- README.md | 2 + crates/gamut-png/README.md | 5 +- crates/gamut-png/STATUS.md | 88 +++++++++++++++++++++++++++- docs/README.md | 1 + docs/benchmarking.md | 117 +++++++++++++++++++++++++++++++++++++ docs/testing.md | 5 ++ 6 files changed, 216 insertions(+), 2 deletions(-) create mode 100644 docs/benchmarking.md diff --git a/README.md b/README.md index 57a77973..4a321e3c 100644 --- a/README.md +++ b/README.md @@ -214,6 +214,8 @@ cargo test --workspace | `mise run lint-fix` | Lint and auto-fix | | `mise run check-commits` | Check commits are Conventional Commits | | `mise run coverage` | Run tests with coverage (min 80%) | +| `mise run bench` | Run performance benchmarks (Divan; see [docs/benchmarking.md](docs/benchmarking.md)) | +| `mise run bench-test` | Run every bench once to prove it still executes (no timings) | | `mise run check-cross ` | Cross-compile-check the libs for a target (extended CI; master/manual) | | `mise run check-msrv` | Check the libs compile on the documented MSRV (extended CI; master/manual) | | `mise run versions` | List every crate's version | diff --git a/crates/gamut-png/README.md b/crates/gamut-png/README.md index e1e02cde..321a0956 100644 --- a/crates/gamut-png/README.md +++ b/crates/gamut-png/README.md @@ -59,7 +59,10 @@ A differential oracle (`tooling/libpng-oracle`, a vendored static libpng) proves libpng decodes the encoder's output pixel-exact, and a libpng *reference encoder* generates the decoder's conformance fixtures (interlaced, sub-byte, forced-filter, metadata-laden) which both decoders must read identically — no vendored image corpus. A hand-crafted malformed-input corpus -pins the rejection policy, and output size is benchmarked against libpng at maximum compression. +pins the rejection policy. Output size is measured against libpng at zlib level 9 by +`cargo bench -p gamut-png`, and **enforced** by `tests/size_contract.rs`, whose per-case budgets +each carry a written justification — a regression in the crate's reason to exist fails the build. +`STATUS.md` records the measured table; gamut is smaller than libpng-9 on every corpus entry. ## License diff --git a/crates/gamut-png/STATUS.md b/crates/gamut-png/STATUS.md index 23faf5c6..67e5a79f 100644 --- a/crates/gamut-png/STATUS.md +++ b/crates/gamut-png/STATUS.md @@ -13,7 +13,8 @@ space optimisation behind the same chunk spine and CRC. FFI), in both directions: libpng decodes the encoder's output → pixel-exact with the source, and a libpng reference-encode entry point generates the decoder's conformance fixtures (interlaced, sub-byte, forced-filter, metadata-laden) that gamut-png and libpng must decode identically. Output -size is benchmarked against libpng at maximum compression. +size is measured against libpng at zlib level 9 by `cargo bench -p gamut-png` and enforced by +`tests/size_contract.rs` (see [Efficiency](#efficiency-issue-224)). **Out of scope:** Adam7 *encoding*, animation/APNG (gamut is image-first; the decoder reads an APNG's default image). Format-agnostic pixel conversion (grey↔RGB, alpha, 16↔8-bit) is @@ -37,6 +38,7 @@ opts into narrowing. That is distinct from the encoder's *lossless* auto-reduce | P8 | §11.3 | Metadata: eXIf, iCCP (deflate-compressed), iTXt-XMP (raw-bytes setters) | ✅ done | | P9 | §4.5 | **Space opt:** lossless palette/gray/alpha-drop reduction (size-estimate chosen) + brute-force filter strategy; extended to grey/grey-alpha/16-bit inputs with lossless 16→8 demotion and sub-byte grey packing (#338) | ✅ done | | P10 | — | CLI `gamut convert → .png`; umbrella `png` feature; final API review | ✅ done | +| E1 | #224 | **Efficiency:** `deconstruct` byte accounting; divan size/bpp + per-stage bench; libpng-9 size contract; opt-in transparent cleanup; palette-vs-native race; `crc32fast` and restructured filter kernels (see [Efficiency](#efficiency-issue-224)) | ✅ done | ## Decoder phases (issue #249) @@ -49,3 +51,87 @@ opts into narrowing. That is distinct from the encoder's *lossless* auto-reduce | D5 | §11.3 | Rich `decode()` → `DecodedPng`: raw eXIf/iCCP/XMP/text payloads (MetadataBlock-ready), parsed gAMA/cHRM/sRGB/cICP, metadata inflation budget | ✅ done | | D6 | — | libpng differential conformance suite over generated fixtures; malformed-input rejection corpus; mutation-gap closure | ✅ done | | D7 | §5, §11.3 | Pixel-free metadata entry point (issue #379): `metadata()` / `PngDecoder::metadata()` → `PngMetadata`, sharing one chunk-classification predicate with `decode()`; IDAT skipped by length, never read or inflated. Mirrors `gamut_jpeg::metadata` / `gamut_webp::metadata` | ✅ done | + +## Efficiency (issue #224) + +Correctness was settled long before efficiency was measured. This section is the measured state: +what the encoder achieves, what it costs, and — per axis — what it does not do yet. + +Everything here is produced by `cargo bench -p gamut-png` and gated by +`tests/size_contract.rs`. One machine, so **read the ratios, not the absolute times**. + +### Output size vs libpng at zlib level 9 + +256×256 unless noted, gamut at `Level::Best` + `FilterStrategy::BruteForce` + auto-reduce. +`+clean` additionally enables `with_transparent_cleanup`. Lower is better. + +| input | raw | default | best | +clean | libpng-9 | best/lp9 | bpp | +| --- | --- | --- | --- | --- | --- | --- | --- | +| `gradient_rgb8` | 196 608 | 2 831 | 2 272 | 2 272 | 2 393 | **−5.1%** | 0.277 | +| `photo_rgb8` | 196 608 | 29 885 | 20 293 | 20 293 | 27 467 | **−26.1%** | 2.477 | +| `noise_rgb8` | 196 608 | 196 983 | 196 983 | 196 983 | 197 280 | −0.2% | 24.046 | +| `grey_as_rgb8` | 196 608 | 721 | 370 | 370 | 566 | **−34.6%** | 0.045 | +| `palette64_rgba8` | 262 144 | 1 274 | 715 | 682 | 1 102 | **−35.1%** | 0.087 | +| `sprite_rgba8` | 262 144 | 4 181 | 3 729 | **2 619** | 3 889 | −4.1% | 0.455 | +| `flat_rgba8` | 262 144 | 821 | 103 | 103 | 664 | **−84.5%** | 0.013 | +| `tiny_rgb8` (16×16) | 768 | 136 | 135 | 135 | 138 | −2.2% | 4.219 | + +gamut is smaller than libpng-9 on every row. The margin is thin where no reduction applies +(`gradient`, `tiny`) or nothing is compressible (`noise`), and large where a lawful +representation change is available that libpng does not attempt. + +### Throughput + +| stage | before | after | | +| --- | --- | --- | --- | +| `crc32` | 420.8 MB/s | 8.996 GB/s | 21× | +| `filter_image` / None | 497.9 MB/s | 16.26 GB/s | 33× | +| `filter_image` / `Fixed(Paeth)` | 277.1 MB/s | 1.202 GB/s | 4.3× | +| `filter_image` / `MinSumAbs` | 46.7 MB/s | 265.8 MB/s | 5.7× | +| `choose_min_sum_abs` | 68.0 MB/s | 308.4 MB/s | 4.5× | + +All safe Rust: `crc32fast` keeps its `unsafe` to itself, and the filter gains are structural +(hoisting a loop-invariant branch, equal-length subslices, one `match` per row instead of per +byte) plus removing a sixth redundant filter pass per scanline. + +### Per-axis state + +| # | Axis | State | +| --- | --- | --- | +| 1 | Filter selection | **partial** — per-line MinSumAbs plus six whole-image candidates each fully DEFLATEd. No entropy or bigram heuristic, no per-line trial deflate, no pruning, no two-tier trial. [#480] | +| 2 | DEFLATE quality | **good, ~2% behind zopfli**, and honestly documented in `gamut-deflate`. Two contained wins remain: an 8-byte-at-a-time match compare, and `parse_dp`'s single-distance relaxation. [#478], [#479] | +| 3 | Smallest lawful representation | **partial** — grey, alpha-drop, ≤256 palette, 16→8, sub-byte all present; a `tRNS` colour key for grey/truecolour is not. [#481] | +| 4 | Palette optimization | **minimal** — trailing-opaque `tRNS` trim only. First-appearance order, no sorting; caller-supplied palettes get no dedupe or unused-entry removal. [#482] | +| 5 | Cleaning invisible data | **done** — `with_transparent_cleanup`, opt-in. Worth 30% on the sprite row. | +| 6 | Metadata hygiene | **no policy** — the encoder emits exactly what the caller set, and `gamut convert` drops metadata on the PNG path. [#483] | +| 7 | Interlacing | **correctly none.** Adam7 costs 5–20%; out of scope by declaration. | +| 8 | Effort / speed / determinism | Output is byte-reproducible (no time, no randomness, and the one `HashMap` is never iterated). Three independent knobs, no composed dial. No parallelism. [#484] | +| 9 | Correctness / robustness | **covered** — 16-bit, odd dimensions, 1×1, CRC policy, malformed input. | + +### The cost model, and why it is a race + +`reduce::analyze8` chooses by comparing **raw** sizes, which does not predict compressed size when +one candidate's bytes are incompressible and the other's are not. A palette carries a `PLTE` (and +often `tRNS`) that DEFLATE cannot touch, while the pixels it replaces may compress by two orders of +magnitude. Measured on `palette64_rgba8`, where `PLTE` + `tRNS` is a flat 273 bytes: + +| side | gamut | IDAT | PLTE+tRNS | libpng-9 | +| --- | --- | --- | --- | --- | +| 128 | 451 | 121 | 273 | 405 | +| 160 | 511 | 181 | 273 | 572 | +| 192 | 564 | 234 | 273 | 707 | +| 256 | 715 | 385 | 273 | 1 102 | + +The estimate sees 16 664 against 65 536 and picks the palette by 4×; the finished files cross over +near 160×160. So `write_reduced_or_native` encodes both candidates and keeps the smaller, the same +way `FilterStrategy::BruteForce` already resolves filters — no tuned constant, and never worse than +either candidate alone. Only palette reductions pay for the second encode; greyscale, alpha-drop +and 16→8 demotion add no chunks, so for them the raw comparison is sound. + +[#478]: https://github.com/visualcommons/gamut/issues/478 +[#479]: https://github.com/visualcommons/gamut/issues/479 +[#480]: https://github.com/visualcommons/gamut/issues/480 +[#481]: https://github.com/visualcommons/gamut/issues/481 +[#482]: https://github.com/visualcommons/gamut/issues/482 +[#483]: https://github.com/visualcommons/gamut/issues/483 +[#484]: https://github.com/visualcommons/gamut/issues/484 diff --git a/docs/README.md b/docs/README.md index 25725652..9a261f87 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,6 +9,7 @@ Anything not listed here is descriptive, not binding. | -------- | ------------- | | [`non-image-media.md`](non-image-media.md) | Whether gamut implements a given audio/video/other-media surface, the crate topology that work lands in, and the [#217]/[#216] roadmaps. Decides scope questions; authorizes no work. | | [`mutation-testing.md`](mutation-testing.md) | How a mutation survey is invoked and what bounds it: the single entry point, the memory budget every parallelism dial is derived from, the guards, and the refusals. What counts as an acceptable survivor is `AGENTS.md`'s rule. | +| [`benchmarking.md`](benchmarking.md) | Where a benchmark lives, what a size or ratio table must record, and where a measured number is kept. What CI does with benches, and what it deliberately does not. Whether a size claim is *enforced* is `testing.md`'s. | | [`testing.md`](testing.md) | Where a test lives and what it may reach, which technique it uses, the per-crate authority table, and the contract by which one law drives both a pinned-seed property test and the fuzz tier. The scope and technique *rules* are `AGENTS.md`'s. | ## Elsewhere in the repo diff --git a/docs/benchmarking.md b/docs/benchmarking.md new file mode 100644 index 00000000..546457b9 --- /dev/null +++ b/docs/benchmarking.md @@ -0,0 +1,117 @@ +# Benchmarking + +Normative for **where a benchmark lives, what a size or ratio table must record, and where a +measured number is kept**. + +Not normative for: whether a size claim is *enforced* — that is a test, and +[`testing.md`](testing.md) places it (the "size / effort contract" row of its technique table). +Nor for the prose a crate uses to describe its own performance, which is that crate's `README.md`. + +> **A benchmark reports. A test asserts. Only the test can fail a build.** + +## Where a benchmark lives + +One file per crate under `crates//benches/`, named for the thing measured (`codec.rs`, +`compression.rs`, `encode.rs`, `pipeline.rs`), declared with `harness = false` and +`divan.workspace = true` in `[dev-dependencies]`. Sixteen crates ship one. + +```toml +[dev-dependencies] +divan.workspace = true + +[[bench]] +name = "encode" +harness = false +``` + +Do **not** use `required-features`. `mise run bench` is `cargo bench --workspace` with no features, +so a bench behind a required feature silently never runs. Gate the feature-dependent *benchmarks* +inside the file instead, and say so in the module doc. + +## What it must state + +Every bench opens with a module doc that names the subject, the issue, what the counter unit means, +and how to run it. The house phrase is "Intentionally tight:", introducing why *these* axes and not +others. + +Counter units are fixed by kind, so figures are comparable across suites: + +| kind | counter | over | +| --- | --- | --- | +| codec encode/decode | `BytesCount` | **source pixel** bytes | +| compressor | `BytesCount` | input bytes | +| container / parser | `BytesCount` | payload bytes | +| per-pixel or per-sample kernel | `ItemsCount` | items | +| one-off construction cost | none | — | + +Fixtures are **generated, never vendored**, and each generator documents the one axis it exists +for. Size them against the algorithm, not for speed: `gamut-png`'s corpus is 256×256 because RGB at +that size is ~6× the DEFLATE window, and a 64×64 image fits *inside* it and would flatter every +encoder equally. + +## Size and ratio tables + +A crate whose reason to exist is output size prints a table before `divan::main()`: + +```rust +fn main() { + print_size_table(); + divan::main(); +} +``` + +The table names its baseline, marks the direction ("lower is better"), and carries a percentage +delta column against that baseline. Where the crate has an oracle, the baseline is the oracle at +its strongest setting — `zlib -9` for `gamut-deflate`, libpng at compression level 9 for +`gamut-png` — configured to do the *same job* on the *same input*. Handing the baseline an +optimisation that is the crate's own contribution does not measure anything. + +Prefer deriving the columns from a reader that works on **any** file rather than from the +encoder's own bookkeeping. `gamut-png`'s table goes through `gamut_png::deconstruct`, which is what +makes its libpng column a measurement rather than two encoders' self-reports. + +## Where a measured number is kept + +In the crate's **`STATUS.md`**, which `docs/README.md` makes normative for implemented state. +Record the invocation, the fixture size, and the caveat that one machine means the ratios are the +result — `gamut-cmm/STATUS.md` and `gamut-png/STATUS.md` are the models. A number in a `README.md` +is a summary of that table, never the source. + +Record negative results too. A heuristic that did not beat the one it was meant to replace is a +finding, and re-deriving it later costs more than writing it down. + +## What CI does + +- **Every PR**: `mise run lint` is `cargo clippy --workspace --all-targets --all-features`, and + `--all-targets` includes benches. They compile, so they cannot rot silently. +- **Extended lane**: `mise run bench-test` is `cargo bench --workspace --benches -- --test`, which + runs every benchmark **once** to prove it still executes — a bench that compiles and then panics + in setup used to be invisible. It takes no timings and asserts no thresholds (issue #437). + +So a benchmark is compiled and executed by CI, and its *numbers* are not gated. Whether they should +be is open, for the reason #437 records: the numbers would come from preemptible shared runners. +Until then, a claim that must not regress belongs in a test — see [`testing.md`](testing.md). + +## Running one + +```bash +mise run bench # the whole workspace +mise run bench-test # run each once, no timings (what Extended does) +cargo bench -p gamut-png # one crate +cargo bench -p gamut-png --bench encode -- --sample-count 50 +``` + +Divan flags must target one harness directly: the per-crate libtest stubs reject them, so +`cargo bench -p --bench -- ` is the form that works. + +## Reaching a crate's internals + +A `benches/` target compiles as a separate crate and sees only `pub` items, which most pipeline +stages are not. The convention is a `test-support` feature exposing a `#[doc(hidden)]` module of +**re-exports only** — no wrapper bodies, which would be executable lines no gate ever runs (bench +targets carry `test = false`) and so would both drag the coverage floor and generate unkillable +mutants. `gamut_png::stages` is the model; the feature is never enabled by the `gamut` umbrella, so +the shipped surface and `mise run check-ffi-features` are unaffected. + +[#437]: https://github.com/visualcommons/gamut/issues/437 +[#149]: https://github.com/visualcommons/gamut/issues/149 diff --git a/docs/testing.md b/docs/testing.md index c4981a79..55039603 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -223,3 +223,8 @@ Compile-time assertions (`gamut-codec-abi/src/lib.rs`'s `const _` ABI pins), the gates, doctests (`mise run test-doc`), benchmarks, and the excluded `tooling/gamut-dng-real-conformance` tier. These are real checks; they are simply not tests this document places or classifies. + +Benchmarks have their own document, [`benchmarking.md`](benchmarking.md): where one lives, what its +tables must record, and where the numbers are kept. The boundary is that a benchmark reports and +only a test can fail a build, so a size claim that must not regress is a **size / effort contract** +here — `gamut-png/tests/size_contract.rs` and `gamut-webp/tests/effort.rs` — not a bench. From 1cc51fd24a01ab29c00330c93946e5dbb4520333 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 15:05:28 -0400 Subject: [PATCH 09/54] test(png): close the mutation gaps in the new efficiency code CI's diff-scoped mutation run surfaced ten survivors across the four shards. None was noise: each one names a claim the new code makes that nothing actually checked. Three needed only a fixture that could tell the difference: * `is_fully_classified`'s `||` and its whole body. `deconstruct` cannot produce a malformed tiling -- it is correct by construction -- so every negative case has to be built by hand. Inline tests now assemble reports with a gap, an empty segment, an overlap, a late start and an early end, each isolating one half of the predicate. * `ChunkStats`'s `count += 1` and `payload_bytes += len`. Every fixture carried at most one chunk of each type, so the accumulate arm never ran and `count` sat at the 1 it is inserted with. Two tests now cover it: a hand-built file with two `tEXt` chunks, and a real multi-IDAT encode that also ties the chunk table back to `idat_compressed`. * `filter_histogram`'s `at += 1 + row_bytes`. Mutated to `*=` the cursor stays at 0 and every row's filter byte is read from the same offset -- indistinguishable while every histogram test forced a *single* filter for the whole image, because both report `height` of it. A fixture whose rows genuinely choose differently now pins that at least two buckets are non-empty. Three were untestable where they stood, and moved rather than being papered over: * The inflation budget (`filtered_len == 0 || filtered_len > MAX`). Reaching the boundary through `deconstruct` would need a real 64 MiB stream either side of the cap, and a hostile IHDR cannot separate `>` from `>=` or `==` because an over-budget file is rejected a second time when the inflated length fails to match. Now `within_inflation_budget`, tested at 0, 1, the cap and one past it. * The palette-vs-native tie-break. Engineering two encodings of one image to land on exactly equal lengths is not something a fixture can do reliably, so `prefers_native` carries the comparison and a unit test pins the documented rule: a tie keeps the palette. * `clean_transparent`'s "is there anything to do" check. Mutated to `!=` it returns `Some(unchanged copy)` for a fully opaque image instead of `None`, which the encoder cannot see -- the bytes are identical either way. The distinction is that the encoder must be able to tell "no work" from "work that changed nothing", or it allocates a whole image for nothing, so the test is on the function. And one was an equivalent mutant, removed rather than tested: the `start < png.len()` guard before pushing a `Truncated` segment can never be false, because `next_chunk` returns `Ok(None)` when nothing is left and only errors with bytes remaining. It was dead code wearing a safety net's clothes; a `debug_assert` records why. Refs #224 --- crates/gamut-png/src/deconstruct.rs | 107 +++++++++++++++++++++++++-- crates/gamut-png/src/encoder.rs | 18 ++++- crates/gamut-png/src/reduce.rs | 37 +++++++++ crates/gamut-png/tests/accounting.rs | 105 +++++++++++++++++++++++++- 4 files changed, 258 insertions(+), 9 deletions(-) diff --git a/crates/gamut-png/src/deconstruct.rs b/crates/gamut-png/src/deconstruct.rs index 614927c4..b7cca440 100644 --- a/crates/gamut-png/src/deconstruct.rs +++ b/crates/gamut-png/src/deconstruct.rs @@ -357,13 +357,19 @@ pub fn deconstruct(png: &[u8]) -> Result { // accounted as one opaque run rather than dropped (§13.2's tolerance, extended to // damage the spec does not describe). Err(_) => { + // `next_chunk` returns `Ok(None)` when nothing is left, so reaching an error means + // bytes remain and this range is never empty. No guard: a `start < png.len()` + // check here can never be false, which makes it dead code and an equivalent + // mutant rather than a safety net. let start = reader.offset(); - if start < png.len() { - segments.push(Segment { - range: start..png.len(), - kind: SegmentKind::Truncated, - }); - } + debug_assert!( + start < png.len(), + "a framing error leaves bytes unaccounted" + ); + segments.push(Segment { + range: start..png.len(), + kind: SegmentKind::Truncated, + }); break; } } @@ -424,6 +430,16 @@ fn pass_stats(header: &ihdr::Ihdr) -> Vec { out } +/// Whether a filtered stream of this length is worth inflating: non-empty, and within the budget. +/// +/// Split out so the boundary is reachable from a unit test. Exercising it through [`deconstruct`] +/// would need a real 64 MiB stream to sit either side of the cap, and a hostile IHDR alone cannot +/// distinguish `>` from `>=` or `==` — every over-budget file is rejected a second time when the +/// inflated length fails to match, so the guard's exact comparison is invisible from outside. +fn within_inflation_budget(filtered_len: usize) -> bool { + filtered_len != 0 && filtered_len <= MAX_FILTERED_BYTES +} + /// Inflates the IDAT stream and counts the filter byte leading each scanline. /// /// `None` whenever the count cannot be trusted: the stream is over budget, corrupt, truncated, @@ -434,7 +450,7 @@ fn filter_histogram( filtered_len: usize, passes: &[PassStats], ) -> Option { - if filtered_len == 0 || filtered_len > MAX_FILTERED_BYTES { + if !within_inflation_budget(filtered_len) { return None; } let stream = inflate::inflate_zlib(idat, filtered_len).ok()?; @@ -452,3 +468,80 @@ fn filter_histogram( } Some(FilterHistogram { counts }) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::ColorType; + + /// A report with the given segment ranges and file length. Built by hand because + /// [`deconstruct`] cannot produce a malformed tiling: it is correct by construction, so every + /// negative case for [`PngReport::is_fully_classified`] has to be assembled here. That is also + /// why these live inline — the predicate is only falsifiable from inside the crate. + fn report_with(ranges: &[(usize, usize)], file_len: usize) -> PngReport { + PngReport { + file_len, + header: PngHeader { + width: 1, + height: 1, + bit_depth: 8, + color_type: ColorType::Truecolor, + interlaced: false, + }, + segments: ranges + .iter() + .map(|&(start, end)| Segment { + range: start..end, + kind: SegmentKind::Trailer, + }) + .collect(), + chunks: Vec::new(), + idat_compressed: 0, + filtered_len: 0, + passes: Vec::new(), + filters: None, + } + } + + #[test] + fn contiguous_segments_covering_the_file_are_fully_classified() { + assert!(report_with(&[(0, 8), (8, 20), (20, 33)], 33).is_fully_classified()); + } + + #[test] + fn a_gap_between_segments_is_not_fully_classified() { + // Every segment is non-empty and the last still reaches `file_len`, so only the + // start-chaining half of the predicate can reject this. + assert!(!report_with(&[(0, 8), (9, 33)], 33).is_fully_classified()); + } + + #[test] + fn an_empty_segment_is_not_fully_classified() { + // The mirror case: the chain is unbroken, so only the non-empty half can reject it. + assert!(!report_with(&[(0, 8), (8, 8), (8, 33)], 33).is_fully_classified()); + } + + #[test] + fn segments_must_start_at_zero_and_reach_the_end() { + assert!(!report_with(&[(4, 33)], 33).is_fully_classified()); + assert!(!report_with(&[(0, 20)], 33).is_fully_classified()); + assert!(!report_with(&[], 33).is_fully_classified()); + // ...and a zero-length file with no segments is vacuously covered. + assert!(report_with(&[], 0).is_fully_classified()); + } + + #[test] + fn the_inflation_budget_is_inclusive_and_rejects_an_empty_stream() { + // Exactly at the cap is worth inflating; one byte past is not. A zero-length stream has + // no scanlines to count and is rejected before any work. + assert!(!within_inflation_budget(0)); + assert!(within_inflation_budget(1)); + assert!(within_inflation_budget(MAX_FILTERED_BYTES)); + assert!(!within_inflation_budget(MAX_FILTERED_BYTES + 1)); + } + + #[test] + fn an_overlap_is_not_fully_classified() { + assert!(!report_with(&[(0, 20), (10, 33)], 33).is_fully_classified()); + } +} diff --git a/crates/gamut-png/src/encoder.rs b/crates/gamut-png/src/encoder.rs index 5173fd33..3b81179d 100644 --- a/crates/gamut-png/src/encoder.rs +++ b/crates/gamut-png/src/encoder.rs @@ -502,7 +502,7 @@ impl PngEncoder { let mut native_encoding = Vec::new(); native(&mut native_encoding)?; - let winner = if native_encoding.len() < palette_encoding.len() { + let winner = if prefers_native(native_encoding.len(), palette_encoding.len()) { native_encoding } else { palette_encoding @@ -589,6 +589,15 @@ impl PngEncoder { } } +/// Whether the unreduced encoding beats the palette one, for [`PngEncoder::write_reduced_or_native`]. +/// +/// **A tie keeps the palette**, which decodes with less work for the same bytes. Split out because +/// engineering two encodings of the same image to land on exactly equal lengths is not something a +/// fixture can do reliably, so the tie is only assertable here. +fn prefers_native(native_len: usize, palette_len: usize) -> bool { + native_len < palette_len +} + /// Writes the zlib datastream as one or more consecutive IDAT chunks. fn write_idat(out: &mut Vec, zlib_stream: &[u8]) { if zlib_stream.is_empty() { @@ -827,6 +836,13 @@ mod tests { assert_eq!(&appended[17..], &fresh[..], "the prefix is left untouched"); } + #[test] + fn a_tie_between_palette_and_native_keeps_the_palette() { + assert!(prefers_native(10, 11), "smaller native wins"); + assert!(!prefers_native(11, 10), "smaller palette wins"); + assert!(!prefers_native(10, 10), "a tie keeps the palette"); + } + #[test] fn brute_force_keeps_the_first_strategy_on_a_tie() { // A 1x1 image compresses to the same length under every strategy, so the tie-break is what diff --git a/crates/gamut-png/src/reduce.rs b/crates/gamut-png/src/reduce.rs index 308dd927..3a81dc23 100644 --- a/crates/gamut-png/src/reduce.rs +++ b/crates/gamut-png/src/reduce.rs @@ -323,6 +323,43 @@ fn build_indexed( mod tests { use super::*; + #[test] + fn cleaning_declines_when_there_is_nothing_invisible_to_clean() { + // `None` rather than an unchanged copy: the encoder must be able to tell "no work" from + // "work that happened to change nothing", or it allocates a whole image for nothing. + let opaque: Vec = (0..16u8).flat_map(|i| [i, i + 1, i + 2, 255]).collect(); + assert!(clean_transparent(&opaque, 4).is_none()); + + // Layouts with no alpha channel have nothing to clean, whatever the samples say. + assert!(clean_transparent(&opaque, 3).is_none()); + assert!(clean_transparent(&opaque, 1).is_none()); + } + + #[test] + fn cleaning_zeroes_invisible_colour_and_leaves_everything_else() { + let src: Vec = vec![ + 10, 20, 30, 255, // visible + 40, 50, 60, 0, // invisible: colour must go + 70, 80, 90, 128, // partially transparent: still visible, must stay + ]; + let cleaned = clean_transparent(&src, 4).expect("there is a transparent pixel"); + assert_eq!( + cleaned, + vec![ + 10, 20, 30, 255, // + 0, 0, 0, 0, // + 70, 80, 90, 128, + ] + ); + } + + #[test] + fn cleaning_grey_alpha_zeroes_only_the_grey_channel() { + let src: Vec = vec![200, 255, 111, 0, 90, 1]; + let cleaned = clean_transparent(&src, 2).expect("there is a transparent pixel"); + assert_eq!(cleaned, vec![200, 255, 0, 0, 90, 1]); + } + #[test] fn drops_opaque_alpha() { // Opaque, non-grey RGBA -> RGB. diff --git a/crates/gamut-png/tests/accounting.rs b/crates/gamut-png/tests/accounting.rs index 6f261468..0124bcd4 100644 --- a/crates/gamut-png/tests/accounting.rs +++ b/crates/gamut-png/tests/accounting.rs @@ -9,7 +9,7 @@ mod common; -use gamut_core::{Dimensions, EncodeImage, ImageRef, Rgb8}; +use gamut_core::{Dimensions, EncodeImage, ImageRef, Rgb8, Rgba8}; use gamut_png::{ ChunkStats, FilterStrategy, FilterType, PngEncoder, Segment, SegmentKind, deconstruct, }; @@ -128,6 +128,71 @@ fn chunk_totals_match_an_independent_scan() { assert_eq!(report.framing_bytes(), report.chunks.len() * 12); } +/// A chunk type that appears more than once must accumulate, not overwrite. +/// +/// Every other fixture here carries at most one chunk of each type, so the accumulate arm of the +/// chunk table never ran: `count` stayed at the 1 it is inserted with and `payload_bytes` at the +/// first chunk's length, and no assertion could tell. +#[test] +fn repeated_chunk_types_accumulate_count_and_payload() { + let first: &[u8] = b"Author\0alice"; + let second: &[u8] = b"Comment\0a considerably longer comment"; + let png = common::png_from_chunks(&[ + common::chunk(b"IHDR", &common::ihdr_payload(4, 4, 8, 2, 0)), + common::chunk(b"tEXt", first), + common::chunk(b"tEXt", second), + common::chunk(b"IDAT", &common::zlib(&[0u8; 4 * (4 * 3 + 1)])), + common::chunk(b"IEND", &[]), + ]); + + let report = deconstruct(&png).expect("deconstruct"); + assert_covers(&report.segments, png.len()); + + let text = report.chunk(b"tEXt").expect("tEXt accounted"); + assert_eq!(text.count, 2, "both chunks counted"); + assert_eq!( + text.payload_bytes, + first.len() + second.len(), + "payloads summed, not overwritten" + ); + assert_eq!(text.framing_bytes(), 24, "12 framing bytes per chunk"); + assert_eq!(text.total_bytes(), first.len() + second.len() + 24); + // The table lists each type once, in first-appearance order. + assert_eq!( + report + .chunks + .iter() + .map(|c| c.chunk_type) + .collect::>(), + vec![*b"IHDR", *b"tEXt", *b"IDAT", *b"IEND"] + ); +} + +/// A stream large enough to split across several IDAT chunks: the same accumulation, on the path +/// that actually produces it in production rather than a hand-built file. +#[test] +fn a_multi_idat_encode_accumulates_every_idat() { + // Incompressible, so the zlib stream stays far above the 64 KiB per-chunk cap. + let (w, h) = (256u32, 256u32); + let src = common::corpus::noise_rgb(w); + let dims = Dimensions::new(w, h).expect("valid dimensions"); + let image = ImageRef::::new(&src, dims).expect("buffer matches dimensions"); + let mut png = Vec::new(); + PngEncoder::new() + .encode_image(image, &mut png) + .expect("encode"); + + let report = deconstruct(&png).expect("deconstruct"); + assert_covers(&report.segments, png.len()); + let idat = report.chunk(b"IDAT").expect("IDAT accounted"); + assert!(idat.count > 1, "the fixture must actually split: {idat:?}"); + assert_eq!( + idat.payload_bytes, report.idat_compressed, + "the chunk table and the compressed total are the same bytes counted twice" + ); + assert!(report.is_intact(), "{report:?}"); +} + #[test] fn trailing_bytes_after_iend_are_a_trailer() { let mut png = encode_rgb(8, 8); @@ -236,6 +301,44 @@ fn the_filter_histogram_matches_the_filter_libpng_was_forced_to_use() { } } +/// The histogram must advance one scanline at a time. +/// +/// Every other histogram assertion here forces a single filter for the whole image, which cannot +/// tell a correct per-row walk from one that re-reads the same byte: both report `height` of the +/// one filter. This fixture's rows choose differently, so a stalled cursor collapses the +/// distribution to a single bucket and is visible. +#[test] +fn the_histogram_walks_each_scanline_not_the_first_one_repeatedly() { + const SIDE: u32 = 64; + let src = common::corpus::sprite_rgba(SIDE); + let dims = Dimensions::new(SIDE, SIDE).expect("valid dimensions"); + let image = ImageRef::::new(&src, dims).expect("buffer matches dimensions"); + let mut png = Vec::new(); + PngEncoder::new() + .with_filter(FilterStrategy::MinSumAbs) + .encode_image(image, &mut png) + .expect("encode"); + + let report = deconstruct(&png).expect("deconstruct"); + let h = report.filters.expect("sound stream"); + assert_eq!(h.total(), SIDE, "one filter byte per scanline"); + + let used = [ + FilterType::None, + FilterType::Sub, + FilterType::Up, + FilterType::Average, + FilterType::Paeth, + ] + .into_iter() + .filter(|&f| h.count(f) > 0) + .count(); + assert!( + used >= 2, + "this fixture's rows must not all choose the same filter, got {used} distinct" + ); +} + #[test] fn interlaced_filtered_length_is_the_per_pass_sum() { // 5x3 and 1x1 leave several Adam7 passes empty; an empty pass contributes no bytes at all, From f360e51c3d2861c603331dcc0a2a319490540309 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 15:12:32 -0400 Subject: [PATCH 10/54] feat(cli): inspect PNG byte accounting `gamut inspect` already answered "did every byte get accounted for?" for TIFF and DNG. For PNG the same walk answers a second question -- where did the bytes go? -- which is what makes an encoder comparison possible from the command line, on files this crate did not write. PNG prints on its own path rather than being flattened into `Summary`. It has no IFD tree and no tag vocabulary, but it carries compression figures the others have no equivalent for, and forcing both through one shape would lose the half that matters. Verified end to end on libpng's own `pngtest.png` -- Adam7 interlaced, 18 chunk types including five this crate does not recognise (`sTER`, `vpAg`, `oFFs`, `pCAL`, `sCAL`): image: 91x69 TruecolorAlpha depth 8, Adam7 interlaced size: 8759 bytes (11.160 bits/pixel) IDAT: 8119 bytes compressed from 25247 filtered (32.2%) overhead: 640 bytes, of which 216 is chunk framing filters: None 21 / Sub 15 / Up 52 / Average 10 / Paeth 33 (131 scanlines) classified: yes intact: yes Every byte of a foreign file classified, and the filter distribution counted across seven Adam7 passes. Truncating it to 4000 bytes reports `truncated from offset 342 (3658 bytes)`, keeps every framing- and IHDR-derived figure, drops only the histogram, and exits non-zero. `Crc32::new`'s lint suppression changes from `expect` to `allow`, and the reason is worth recording: `clippy::new_without_default` only fires when `test-support` re-exports the type through `crate::stages`, so an `expect` is *unfulfilled* in a default-feature build and fails there instead. That is `expect` working correctly -- it caught its own obsolescence in one of two configurations -- but a feature-dependent lint wants `allow`. Refs #224 --- crates/gamut-cli/src/commands/inspect.rs | 151 ++++++++++++++++++++++- crates/gamut-cli/src/main.rs | 2 +- crates/gamut-png/src/crc32.rs | 6 +- 3 files changed, 154 insertions(+), 5 deletions(-) diff --git a/crates/gamut-cli/src/commands/inspect.rs b/crates/gamut-cli/src/commands/inspect.rs index 7debfb0b..8eb2a8bd 100644 --- a/crates/gamut-cli/src/commands/inspect.rs +++ b/crates/gamut-cli/src/commands/inspect.rs @@ -1,9 +1,14 @@ -//! `gamut inspect` — strict "deconstruct" of a TIFF or DNG (issues #197/#263). +//! `gamut inspect` — strict "deconstruct" of a TIFF, DNG or PNG (issues #197/#263/#224). //! //! Walks the entire container, classifies every byte into typed segments, and flags anything //! unrecognised (unknown tags, unknown field types, out-of-spec codes, unclassified bytes). //! Prints a report to stdout and exits non-zero when the file is not fully accounted for — //! usable as an archival CI gate. +//! +//! For PNG the same walk answers a second question: **where did the bytes go?** The report carries +//! the per-chunk-type breakdown, the compressed IDAT total against the filtered stream it inflates +//! to, and the scanline filter distribution — which is what makes an encoder comparison possible +//! from the command line, on files this crate did not write. use std::path::PathBuf; @@ -21,7 +26,7 @@ const DNG_VERSION_TAG: u16 = 50706; /// Arguments for `gamut inspect`. #[derive(Args)] pub(crate) struct InspectArgs { - /// Input TIFF or DNG file. + /// Input TIFF, DNG or PNG file. input: PathBuf, /// Force the container format instead of auto-detecting it. #[arg(long, value_enum)] @@ -35,6 +40,8 @@ pub(crate) enum Format { Tiff, /// DNG (Adobe Digital Negative; gamut-dng). Dng, + /// PNG (gamut-png). + Png, } /// A format-agnostic view of a deconstruct report, for printing. @@ -56,9 +63,17 @@ pub(crate) fn run(args: &InspectArgs) -> Result<(), CliError> { })?; let format = args.format.unwrap_or_else(|| sniff(&data)); + // PNG's report is a different shape -- it has no IFD tree and no tag vocabulary, but it does + // carry compression figures the others have no equivalent for -- so it prints on its own path + // rather than being flattened into `Summary`. + if matches!(format, Format::Png) { + return inspect_png(&args.input, &data); + } + let summary = match format { Format::Dng => summarize_dng(gamut::dng::deconstruct(&data)?), Format::Tiff => summarize_tiff(gamut::tiff::deconstruct(&data)?), + Format::Png => unreachable!("handled above"), }; print_summary(&args.input, format, &summary); @@ -77,8 +92,15 @@ pub(crate) fn run(args: &InspectArgs) -> Result<(), CliError> { } } -/// Detects DNG vs TIFF: a DNG is a TIFF whose IFD 0 carries the mandatory `DNGVersion` tag. +/// The 8-byte PNG file signature (§5.2). +const PNG_SIGNATURE: [u8; 8] = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; + +/// Detects PNG by signature, then DNG vs TIFF: a DNG is a TIFF whose IFD 0 carries the mandatory +/// `DNGVersion` tag. fn sniff(data: &[u8]) -> Format { + if data.starts_with(&PNG_SIGNATURE) { + return Format::Png; + } if let Ok(file) = gamut::tiff::read(data) && file .ifds @@ -339,10 +361,133 @@ fn print_lines(label: &str, lines: &[String]) { } /// The display name of a format. +/// Deconstructs a PNG and prints where its bytes went, exiting non-zero when the file is not a +/// complete, undamaged datastream. +fn inspect_png(path: &std::path::Path, data: &[u8]) -> Result<(), CliError> { + use gamut::png::{FilterType, SegmentKind}; + + let report = gamut::png::deconstruct(data)?; + let header = report.header; + + println!("{}: PNG", path.display()); + println!( + " image: {}x{} {:?} depth {}{}", + header.width, + header.height, + header.color_type, + header.bit_depth, + if header.interlaced { + ", Adam7 interlaced" + } else { + "" + } + ); + println!( + " size: {} bytes ({:.3} bits/pixel)", + report.file_len, + report.bits_per_pixel() + ); + println!( + " IDAT: {} bytes compressed from {} filtered ({:.1}%)", + report.idat_compressed, + report.filtered_len, + report.idat_ratio() * 100.0 + ); + println!( + " overhead: {} bytes, of which {} is chunk framing", + report.overhead_bytes(), + report.framing_bytes() + ); + + println!(" chunks:"); + for stats in &report.chunks { + println!( + " {} x{:<3} {:>9} payload + {:>4} framing{}", + String::from_utf8_lossy(&stats.chunk_type), + stats.count, + stats.payload_bytes, + stats.framing_bytes(), + if stats.is_ancillary() { + " (ancillary)" + } else { + "" + } + ); + } + + match report.filters { + Some(h) => { + let n = |f| h.count(f); + println!( + " filters: None {} / Sub {} / Up {} / Average {} / Paeth {} ({} scanlines)", + n(FilterType::None), + n(FilterType::Sub), + n(FilterType::Up), + n(FilterType::Average), + n(FilterType::Paeth), + h.total() + ); + } + None => println!(" filters: unavailable (IDAT not inflatable within budget)"), + } + + if report.passes.len() > 1 { + println!(" Adam7 passes:"); + for pass in &report.passes { + println!( + " {}: {}x{}, {} row bytes, {} filtered", + pass.index, pass.width, pass.height, pass.row_bytes, pass.filtered_len + ); + } + } + + let damaged: Vec = report + .segments + .iter() + .filter_map(|seg| match seg.kind { + SegmentKind::Chunk { + chunk_type, + crc_ok: false, + .. + } => Some(format!( + "CRC mismatch in {} at offset {}", + String::from_utf8_lossy(&chunk_type), + seg.range.start + )), + SegmentKind::Truncated => Some(format!( + "truncated from offset {} ({} bytes)", + seg.range.start, + seg.range.len() + )), + SegmentKind::Trailer => Some(format!( + "{} trailing bytes after IEND at offset {}", + seg.range.len(), + seg.range.start + )), + _ => None, + }) + .collect(); + print_lines("findings", &damaged); + + println!(" classified: {}", yes_no(report.is_fully_classified())); + println!(" intact: {}", yes_no(report.is_intact())); + + if report.is_intact() { + Ok(()) + } else { + Err(CliError::NotFullyAccounted(format!( + "{}: not a complete, undamaged PNG datastream — {} finding(s)", + path.display(), + damaged.len() + ))) + } +} + fn format_name(format: Format) -> &'static str { match format { Format::Tiff => "TIFF", Format::Dng => "DNG", + Format::Png => "PNG", } } diff --git a/crates/gamut-cli/src/main.rs b/crates/gamut-cli/src/main.rs index debb0c3a..10000b26 100644 --- a/crates/gamut-cli/src/main.rs +++ b/crates/gamut-cli/src/main.rs @@ -62,7 +62,7 @@ struct Cli { enum Command { /// Decode an image (PNG/JPEG/PPM/WebP/JXL) and re-encode it as AVIF/WebP/TIFF/PNG/JXL/JPEG. Convert(commands::convert::ConvertArgs), - /// Strictly deconstruct a TIFF or DNG: account every byte and flag unknowns (gamut-tiff/gamut-dng). + /// Strictly deconstruct a TIFF, DNG or PNG: account every byte, flag unknowns, and for PNG report where the bytes went (gamut-tiff/gamut-dng/gamut-png). Inspect(commands::inspect::InspectArgs), /// Extract and inspect the embedded ICC colour profile of an image (gamut-icc). Icc(commands::icc::IccArgs), diff --git a/crates/gamut-png/src/crc32.rs b/crates/gamut-png/src/crc32.rs index c7cc2aad..09d2c2c2 100644 --- a/crates/gamut-png/src/crc32.rs +++ b/crates/gamut-png/src/crc32.rs @@ -24,7 +24,11 @@ impl Crc32 { // No `Default` impl to pair with this: nothing in the crate would call it, so it would be an // uncovered region and an unkillable mutant -- a delegation no test can reach. `new` is only // `pub` so `crate::stages` can re-export it to the benchmark driver. - #[expect( + // `allow`, not `expect`: the lint only fires when `test-support` re-exports this type through + // `crate::stages`, so an `expect` is unfulfilled in a default-feature build and fails there + // instead. A `Default` impl would be dead delegation -- nothing in the crate calls it, so it + // would be an uncovered region and a mutant no test could kill. + #[allow( clippy::new_without_default, reason = "a Default impl here would be dead delegation: uncovered, and unkillable by any test" )] From 54eb16052e7dfb39c43b29807e72fc6f5dcbf61c Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 15:23:31 -0400 Subject: [PATCH 11/54] feat(png): reduce binary alpha to a tRNS colour key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The one lawful PNG representation this encoder could not write. The crate said so itself, at `decoder.rs:1327`: "the encoder cannot write interlaced files or greyscale/truecolour tRNS colour keys". The decoder has always read them, so only the encoder half was missing. Three conditions, all necessary, because §11.3.2.1 gives a decoder exactly one transparent colour and not a mask: every alpha is 0 or 255; at least one pixel is transparent; and every transparent pixel shares one colour that no opaque pixel uses. That last one is why `with_transparent_cleanup` pairs with this -- it collapses every invisible pixel to one colour, which is precisely what a key needs. Two passes, not one: the candidate is unknown until the first transparent pixel is seen, so proving no *earlier* opaque pixel used it needs a second look. The second only runs once the first has found a candidate. The measurement changed the design twice, and both are recorded in the code because neither is guessable: * **It is worth ~7-9%, not the 25% the raw-byte arithmetic suggests.** Dropping a channel removes 25% of the samples, but the alpha plane is usually the most compressible plane in the image, so most of that is already free. On a 128x128 sprite: 863 bytes keyed against 926 plain. * **Only on a contiguous transparent region.** With the transparency scattered by a hash instead, the invisible colour interleaves with the visible gradient and wrecks the RGB channels' compressibility: `RGB+tRNS` came out at 14 886 bytes against plain RGBA's 14 319, and the race correctly declined the key. The first version of the fixture here was scattered, and the tests failed until the shape matched what real sprites and icons actually look like. So keyed encodings join `Indexed` in `write_reduced_or_native`'s race rather than being taken on the estimate. A `tRNS` chunk is incompressible in exactly the way a `PLTE` is, and the same raw-size blind spot applies: at 32x32 and 64x64 the analysis offers a key and the race is right to refuse it. Tests go through libpng in every case rather than round-tripping gamut against itself: gamut writes the key and libpng interprets it, so a round trip could agree on a wrong convention and prove nothing. That includes pinning the payload bytes, since §11.3.2.1 wants three *16-bit big-endian* samples and a decoder reading them as three bytes would key on the wrong colour. Refs #224. Closes #481. --- crates/gamut-png/src/encoder.rs | 34 ++++- crates/gamut-png/src/reduce.rs | 107 ++++++++++++- crates/gamut-png/tests/colour_key.rs | 220 +++++++++++++++++++++++++++ 3 files changed, 356 insertions(+), 5 deletions(-) create mode 100644 crates/gamut-png/tests/colour_key.rs diff --git a/crates/gamut-png/src/encoder.rs b/crates/gamut-png/src/encoder.rs index 3b81179d..04aa4910 100644 --- a/crates/gamut-png/src/encoder.rs +++ b/crates/gamut-png/src/encoder.rs @@ -484,9 +484,9 @@ impl PngEncoder { /// needs no tuned constant, and it cannot be worse than either candidate alone. A tie keeps /// the palette, which decodes with less work. /// - /// Only palette reductions pay for the second encode. Greyscale, alpha-drop and 16→8 - /// demotion add no chunks at all, so for them the raw comparison is sound and this returns - /// immediately. + /// Only the reductions that *carry a chunk* pay for the second encode — a palette's `PLTE` + /// (+ `tRNS`), or a colour key's `tRNS`. Greyscale, alpha-drop and 16→8 demotion add no chunks + /// at all, so for them the raw comparison is sound and this returns immediately. fn write_reduced_or_native( &self, dims: Dimensions, @@ -494,7 +494,11 @@ impl PngEncoder { native: impl FnOnce(&mut Vec) -> Result, out: &mut Vec, ) -> Result { - if !matches!(reduced, Reduced::Indexed { .. }) { + let carries_chunks = matches!( + reduced, + Reduced::Indexed { .. } | Reduced::Rgb8Keyed { .. } | Reduced::GrayKeyed { .. } + ); + if !carries_chunks { return self.write_reduced(dims, reduced, out); } let mut palette_encoding = Vec::new(); @@ -541,6 +545,28 @@ impl PngEncoder { Reduced::Rgb8(samples) => { self.write_png(wh, &samples, ColorType::Truecolor, 8, |_| {}, out) } + // §11.3.2.1: for truecolour, tRNS is three 16-bit big-endian samples naming the one + // colour a decoder renders as fully transparent. At depth 8 the high byte is zero. + Reduced::Rgb8Keyed { samples, key } => self.write_png( + wh, + &samples, + ColorType::Truecolor, + 8, + |out| { + let trns = [0, key[0], 0, key[1], 0, key[2]]; + chunk::write_chunk(out, *b"tRNS", &trns); + }, + out, + ), + // ...and for greyscale, one 16-bit big-endian sample. + Reduced::GrayKeyed { samples, key } => self.write_png( + wh, + &samples, + ColorType::Grayscale, + 8, + |out| chunk::write_chunk(out, *b"tRNS", &[0, key]), + out, + ), Reduced::Rgba8(samples) => { self.write_png(wh, &samples, ColorType::TruecolorAlpha, 8, |_| {}, out) } diff --git a/crates/gamut-png/src/reduce.rs b/crates/gamut-png/src/reduce.rs index 3a81dc23..497ca50c 100644 --- a/crates/gamut-png/src/reduce.rs +++ b/crates/gamut-png/src/reduce.rs @@ -35,6 +35,24 @@ pub enum Reduced { GrayAlpha16Be(Vec), /// 16-bit RGB (alpha was fully opaque and dropped), pre-serialised big-endian. Rgb16Be(Vec), + /// 8-bit RGB plus a `tRNS` colour key (§11.3.2.1): the alpha channel was binary, every + /// transparent pixel shared one colour, and no opaque pixel used it, so that colour can stand + /// for "transparent" and the fourth channel disappears. + Rgb8Keyed { + /// One RGB triple per pixel. + samples: Vec, + /// The colour a decoder must render as fully transparent. + key: [u8; 3], + }, + /// Greyscale plus a `tRNS` colour key — the greyscale twin of [`Reduced::Rgb8Keyed`]. Always + /// depth 8: a sub-byte depth would have to scale the key too, and the saving over depth 8 is + /// smaller than the risk of getting that wrong. + GrayKeyed { + /// One grey sample per pixel. + samples: Vec, + /// The grey value a decoder must render as fully transparent. + key: u8, + }, /// Indexed colour with the smallest sufficient bit depth. Indexed { /// Index bit depth (1, 2, 4, or 8). @@ -116,6 +134,59 @@ fn pixel_key(px: &[u8], channels: usize) -> [u8; 4] { } } +/// The colour that can stand for "transparent", if a `tRNS` colour key applies at all. +/// +/// Three conditions, all necessary (§11.3.2.1 gives a decoder exactly one transparent colour, not +/// a mask): +/// +/// 1. every alpha is 0 or 255 — a partially transparent pixel cannot be expressed by a key; +/// 2. at least one pixel is transparent — otherwise the plain alpha *drop* already applies and is +/// strictly better, since it costs no chunk; +/// 3. every transparent pixel shares one colour, and **no opaque pixel uses it** — otherwise the +/// key would erase a pixel that should be visible. +/// +/// Condition 3 is why +/// [`PngEncoder::with_transparent_cleanup`](crate::PngEncoder::with_transparent_cleanup) pairs +/// with this: it collapses every invisible pixel to one colour, which is precisely what a key +/// needs. Without it, a source whose transparent pixels carry different unseen colours has no key +/// available and keeps its alpha channel. +/// +/// Two passes rather than one: the candidate is not known until the first transparent pixel is +/// seen, so proving no *earlier* opaque pixel used it needs a second look. The second pass only +/// runs when the first has already established a candidate. +fn colour_key(pixels: &[u8], channels: usize) -> Option<[u8; 4]> { + debug_assert!(channels == 2 || channels == 4); + let mut candidate: Option<[u8; 4]> = None; + let mut any_transparent = false; + for px in pixels.chunks_exact(channels) { + let key = pixel_key(px, channels); + match key[3] { + 0 => { + any_transparent = true; + match candidate { + // A second transparent colour: no single key can stand for both. + Some(seen) if seen[..3] != key[..3] => return None, + Some(_) => {} + None => candidate = Some(key), + } + } + 255 => {} + // Partial transparency cannot be expressed as a colour key. + _ => return None, + } + } + if !any_transparent { + return None; + } + let candidate = candidate?; + // The key must name a colour nothing visible uses. + let collides = pixels.chunks_exact(channels).any(|px| { + let key = pixel_key(px, channels); + key[3] == 255 && key[..3] == candidate[..3] + }); + (!collides).then_some(candidate) +} + /// Analyses interleaved 8-bit samples (`channels`: 1 = grey, 2 = grey+alpha, 3 = RGB, 4 = RGBA) /// and returns the smallest lossless reduction that beats the input encoding, or `None` to keep it /// as-is. @@ -182,11 +253,25 @@ pub fn analyze8(pixels: &[u8], channels: usize) -> Option { } else { usize::MAX }; + // A colour key costs one `tRNS` chunk -- 6 bytes of payload for truecolour, 2 for greyscale, + // plus 12 of framing -- and buys the whole alpha channel. Only worth looking for when alpha is + // actually carrying something, which `all_opaque` already rules out. + let key = if all_opaque || !channels.is_multiple_of(2) { + None + } else { + colour_key(pixels, channels) + }; + let keyed_size = match key { + Some(_) if all_gray => pixel_count + 14, + Some(_) => pixel_count * 3 + 18, + None => usize::MAX, + }; let best = palette_size .min(gray_size) .min(gray_alpha_size) - .min(rgb_size); + .min(rgb_size) + .min(keyed_size); if best >= input_size { return None; // no reduction is smaller } @@ -208,6 +293,26 @@ pub fn analyze8(pixels: &[u8], channels: usize) -> Option { out.push(key[3]); } Some(Reduced::GrayAlpha8(out)) + } else if best == keyed_size { + let key = key.expect("keyed_size is only finite when a key was found"); + if all_gray { + Some(Reduced::GrayKeyed { + samples: pixels + .chunks_exact(channels) + .map(|px| pixel_key(px, channels)[0]) + .collect(), + key: key[0], + }) + } else { + let mut out = Vec::with_capacity(pixel_count * 3); + for px in pixels.chunks_exact(channels) { + out.extend_from_slice(&pixel_key(px, channels)[0..3]); + } + Some(Reduced::Rgb8Keyed { + samples: out, + key: [key[0], key[1], key[2]], + }) + } } else if best == rgb_size { let mut out = Vec::with_capacity(pixel_count * 3); for px in pixels.chunks_exact(channels) { diff --git a/crates/gamut-png/tests/colour_key.rs b/crates/gamut-png/tests/colour_key.rs new file mode 100644 index 00000000..26f5541e --- /dev/null +++ b/crates/gamut-png/tests/colour_key.rs @@ -0,0 +1,220 @@ +//! The `tRNS` colour key reduction (issue #224, axis 3): dropping a binary alpha channel by +//! naming one colour "transparent" (§11.3.2.1). +//! +//! This is a *lossless* reduction, so the claim is exact: libpng must decode the keyed file to +//! byte-identical RGBA. That is the only assertion that matters, and it is why every test here +//! goes through the oracle rather than round-tripping gamut against itself — the key is written +//! by gamut and interpreted by libpng, so a round trip could agree on a wrong convention. + +mod common; + +use gamut_core::{Dimensions, EncodeImage, ImageRef, Rgba8}; +use gamut_png::{FilterStrategy, Level, PngEncoder, deconstruct}; + +/// 128, not something smaller, and the reason is the whole design of the reduction. +/// +/// A colour key costs a flat 18-byte `tRNS` chunk that DEFLATE cannot touch, and buys an alpha +/// plane that usually compresses very well. So whether it wins is size-dependent, exactly as the +/// palette is: measured on this fixture the analysis offers `Rgb8Keyed` at every size, and +/// `write_reduced_or_native` keeps plain RGBA below this size before taking the key at 128, +/// where it is worth about 7% (863 bytes against 926). +/// +/// That also matters for the *negative* tests below. Asserting "stayed RGBA" at a size where the +/// key would never have been taken anyway proves nothing; at 128 a valid key is taken, so RGBA +/// there is real evidence the reduction declined. +const SIDE: u32 = 128; + +fn encode(samples: &[u8]) -> Vec { + let dims = Dimensions::new(SIDE, SIDE).expect("valid dimensions"); + let image = ImageRef::::new(samples, dims).expect("buffer matches dimensions"); + let mut out = Vec::new(); + PngEncoder::new() + .with_compression(Level::Best) + .with_filter(FilterStrategy::BruteForce) + .with_auto_reduce(true) + .encode_image(image, &mut out) + .expect("encode"); + out +} + +/// Whether this pixel is outside the visible shape. +/// +/// A **contiguous** region, and that was measured rather than assumed. Scattering the +/// transparency instead — an avalanche hash over the pixel index — makes the key a net *loss*: +/// the invisible colour then interleaves with the visible gradient and wrecks the RGB channels' +/// compressibility, so `RGB + tRNS` came out at 14 886 bytes against plain RGBA's 14 319 and the +/// race correctly declined it. A solid transparent region keeps the colour channels smooth, which +/// is the shape real sprites and icons have and the shape where dropping the alpha plane pays. +fn outside(x: u32, y: u32) -> bool { + let cx = i64::from(x) - i64::from(SIDE) / 2; + let cy = i64::from(y) - i64::from(SIDE) / 2; + cx * cx + cy * cy >= (i64::from(SIDE) * i64::from(SIDE)) / 9 +} + +/// Binary alpha, one shared invisible colour, and enough distinct visible colours that a palette +/// is not on the table — so the colour key is the only reduction available. +fn keyable_rgba() -> Vec { + let mut buf = Vec::with_capacity((SIDE * SIDE * 4) as usize); + for y in 0..SIDE { + for x in 0..SIDE { + if outside(x, y) { + // Invisible, all sharing one colour no visible pixel below can produce. + buf.extend_from_slice(&[1, 2, 3, 0]); + } else { + buf.extend_from_slice(&[(x * 2) as u8, (y * 2) as u8, 200, 255]); + } + } + } + buf +} + +#[test] +fn a_colour_key_drops_the_alpha_channel_losslessly() { + let src = keyable_rgba(); + let png = encode(&src); + let report = deconstruct(&png).expect("deconstruct"); + + assert_eq!( + libpng_oracle::decode(&png).color_type, + libpng_oracle::COLOR_RGB, + "the alpha channel is gone" + ); + assert!( + report.chunk(b"tRNS").is_some(), + "and a colour key replaced it" + ); + + // The whole claim: libpng renders the key, and every pixel comes back exactly. + let (_, _, rgba) = libpng_oracle::decode_rgba8(&png); + assert_eq!(rgba, src, "the colour key resolves losslessly"); +} + +#[test] +fn the_key_is_written_as_sixteen_bit_big_endian_samples() { + // §11.3.2.1: truecolour tRNS is three 16-bit big-endian samples, not three bytes. At depth 8 + // the high byte of each is zero — a decoder reading it as bytes would key on the wrong + // colour, and libpng's round trip above would fail rather than this, so pin the bytes too. + let png = encode(&keyable_rgba()); + let trns = read_chunk(&png, b"tRNS").expect("tRNS present"); + assert_eq!(trns, vec![0, 1, 0, 2, 0, 3], "the key is (1, 2, 3)"); +} + +#[test] +fn partial_transparency_keeps_the_alpha_channel() { + // A key can only say "fully transparent"; anything in between must keep a real alpha channel. + let mut src = keyable_rgba(); + src[7] = 128; // one pixel's alpha, neither 0 nor 255 + let png = encode(&src); + + assert_eq!( + libpng_oracle::decode(&png).color_type, + libpng_oracle::COLOR_RGBA, + "partial alpha is not expressible as a key" + ); + let (_, _, rgba) = libpng_oracle::decode_rgba8(&png); + assert_eq!(rgba, src); +} + +#[test] +fn a_colour_a_visible_pixel_uses_cannot_be_the_key() { + // The invisible pixels all share (0, 0, 200) — but so does a visible one. Keying on it would + // erase a pixel a viewer should see, so the reduction must decline. + let mut buf = Vec::with_capacity((SIDE * SIDE * 4) as usize); + for y in 0..SIDE { + for x in 0..SIDE { + if outside(x, y) { + buf.extend_from_slice(&[0, 0, 200, 0]); + } else { + buf.extend_from_slice(&[(x * 2) as u8, (y * 2) as u8, 200, 255]); + } + } + } + // Plant the collision on a pixel that is definitely visible: the centre. + let centre = ((SIDE / 2) * SIDE + SIDE / 2) as usize * 4; + buf[centre..centre + 4].copy_from_slice(&[0, 0, 200, 255]); + + let png = encode(&buf); + assert_eq!( + libpng_oracle::decode(&png).color_type, + libpng_oracle::COLOR_RGBA, + "the only candidate key is in use by a visible pixel" + ); + let (_, _, rgba) = libpng_oracle::decode_rgba8(&png); + assert_eq!(rgba, buf); +} + +#[test] +fn two_different_invisible_colours_have_no_single_key() { + let mut src = keyable_rgba(); + // A second transparent colour: no one key can stand for both. + src[0..4].copy_from_slice(&[9, 9, 9, 0]); + let png = encode(&src); + + assert_eq!( + libpng_oracle::decode(&png).color_type, + libpng_oracle::COLOR_RGBA, + "two invisible colours cannot share one key" + ); + let (_, _, rgba) = libpng_oracle::decode_rgba8(&png); + assert_eq!(rgba, src); +} + +#[test] +fn cleanup_makes_an_unkeyable_image_keyable() { + // The compounding case the cleanup pass exists for: transparent pixels carrying different + // unseen colours have no key, until cleaning collapses them to one. + let src = common::corpus::sprite_rgba(SIDE); + let dims = Dimensions::new(SIDE, SIDE).expect("valid dimensions"); + + let mut plain = Vec::new(); + PngEncoder::new() + .with_compression(Level::Best) + .with_auto_reduce(true) + .encode_image( + ImageRef::::new(&src, dims).expect("buffer"), + &mut plain, + ) + .expect("encode"); + + let mut cleaned = Vec::new(); + PngEncoder::new() + .with_compression(Level::Best) + .with_auto_reduce(true) + .with_transparent_cleanup(true) + .encode_image( + ImageRef::::new(&src, dims).expect("buffer"), + &mut cleaned, + ) + .expect("encode"); + + // Whatever each lands on, the visible pixels must survive both. + for png in [&plain, &cleaned] { + let (_, _, rgba) = libpng_oracle::decode_rgba8(png); + for (a, b) in rgba.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0) { + if b[3] != 0 { + assert_eq!(a, b, "a visible pixel changed"); + } + assert_eq!(a[3], b[3], "alpha changed"); + } + } + assert!( + cleaned.len() <= plain.len(), + "cleaning must not cost bytes: {} vs {}", + cleaned.len(), + plain.len() + ); +} + +/// The payload of the first chunk of this type, if present. +fn read_chunk(png: &[u8], want: &[u8; 4]) -> Option> { + let mut at = 8usize; + while at + 12 <= png.len() { + let len = u32::from_be_bytes([png[at], png[at + 1], png[at + 2], png[at + 3]]) as usize; + let ty = &png[at + 4..at + 8]; + if ty == want { + return Some(png[at + 8..at + 8 + len].to_vec()); + } + at += 12 + len; + } + None +} From 1529ab030d17d9d429dfff58fe68421980923756 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 15:23:41 -0400 Subject: [PATCH 12/54] docs(png): record the colour key in the axis scorecard Axis 3 moves to done, with the measured figure rather than the raw-byte one: ~7-9% on a contiguous transparent region, because the alpha plane a key removes is usually the most compressible plane in the image. Refs #224 --- crates/gamut-png/STATUS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/gamut-png/STATUS.md b/crates/gamut-png/STATUS.md index 67e5a79f..d328cfbf 100644 --- a/crates/gamut-png/STATUS.md +++ b/crates/gamut-png/STATUS.md @@ -100,9 +100,9 @@ byte) plus removing a sixth redundant filter pass per scanline. | --- | --- | --- | | 1 | Filter selection | **partial** — per-line MinSumAbs plus six whole-image candidates each fully DEFLATEd. No entropy or bigram heuristic, no per-line trial deflate, no pruning, no two-tier trial. [#480] | | 2 | DEFLATE quality | **good, ~2% behind zopfli**, and honestly documented in `gamut-deflate`. Two contained wins remain: an 8-byte-at-a-time match compare, and `parse_dp`'s single-distance relaxation. [#478], [#479] | -| 3 | Smallest lawful representation | **partial** — grey, alpha-drop, ≤256 palette, 16→8, sub-byte all present; a `tRNS` colour key for grey/truecolour is not. [#481] | +| 3 | Smallest lawful representation | **done** — grey, alpha-drop, ≤256 palette, 16→8, sub-byte, and a `tRNS` colour key for grey/truecolour. The key is worth ~7–9% on a contiguous transparent region, *not* the 25% the raw-byte arithmetic suggests: the alpha plane it removes is usually the most compressible plane in the image. | | 4 | Palette optimization | **minimal** — trailing-opaque `tRNS` trim only. First-appearance order, no sorting; caller-supplied palettes get no dedupe or unused-entry removal. [#482] | -| 5 | Cleaning invisible data | **done** — `with_transparent_cleanup`, opt-in. Worth 30% on the sprite row. | +| 5 | Cleaning invisible data | **done** — `with_transparent_cleanup`, opt-in. Worth 30% on the sprite row, and it is what makes a colour key reachable at all on a source whose invisible pixels carry different unseen colours. | | 6 | Metadata hygiene | **no policy** — the encoder emits exactly what the caller set, and `gamut convert` drops metadata on the PNG path. [#483] | | 7 | Interlacing | **correctly none.** Adam7 costs 5–20%; out of scope by declaration. | | 8 | Effort / speed / determinism | Output is byte-reproducible (no time, no randomness, and the one `HashMap` is never iterated). Three independent knobs, no composed dial. No parallelism. [#484] | From 364efa9cf800f684c153a1a22c039df9efbfe389 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 15:42:01 -0400 Subject: [PATCH 13/54] feat(png): order the palette, and close the colour-key mutation gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Palette index order is not free. It decides the `tRNS` chunk's length, and it decides what the row filters see, because a filtered index stream is the *difference* between neighbouring indices. Discovery order -- raster scan -- optimises neither. Two rules. Transparent entries first, so the trailing-opaque `tRNS` trim cuts as much as §11.3.2.1 allows; one late transparent entry used to pin the whole chunk to full length. Then by Rec. 601 luma, so neighbouring indices are neighbouring brightnesses and a smoothly shaded image produces small index deltas rather than the arbitrary jumps discovery order gives. Measured by disabling the ordering alone, so the figure is not confounded with the colour key landing in the same branch: row unordered ordered sprite_rgba8 +clean 2619 2235 -14.7% palette64_rgba8 715 726 +1.5% A real trade, and worth stating rather than rounding to "it helps". The sprite's gain is 35x the palette64 loss, and palette64's colours are synthetic ramps whose discovery order already correlates with index adjacency -- the case luma sorting is least able to improve and most able to disturb. The full modified-Zeng ordering oxipng uses remains #482. The rest of this commit closes the mutation gaps CI found in the previous commit's colour key. All seven were in the cost estimate -- the guard deciding whether to look for a key, the match on `all_gray`, and the arithmetic in both arms -- and they share one cause worth recording, because it will recur: **`write_reduced_or_native` makes the estimate much less observable.** A mutated cost still produces a keyed candidate, which still races the unreduced encoding, and the smaller still wins. So perturbing the estimate usually changes which candidate is *offered* without changing the bytes that finally win. That is the race doing its job -- it is exactly why the estimate stopped being load-bearing -- but it means an estimate can no longer be tested through the encoder. So the arithmetic moves into `may_have_colour_key` and `keyed_size`, tested directly, with the chunk costs as named constants derived from the spec (2 + 12 for greyscale, 6 + 12 for truecolour) rather than as literals. Same treatment the inflation budget and the palette tie-break already got. Refs #224. Closes #482. --- crates/gamut-png/src/reduce.rs | 125 ++++++++++++++++++++++++++++----- 1 file changed, 106 insertions(+), 19 deletions(-) diff --git a/crates/gamut-png/src/reduce.rs b/crates/gamut-png/src/reduce.rs index 497ca50c..8cffeafc 100644 --- a/crates/gamut-png/src/reduce.rs +++ b/crates/gamut-png/src/reduce.rs @@ -134,6 +134,36 @@ fn pixel_key(px: &[u8], channels: usize) -> [u8; 4] { } } +/// A `tRNS` chunk's cost for a greyscale image: one 16-bit sample plus 12 bytes of framing. +const GREY_KEY_COST: usize = 2 + 12; + +/// A `tRNS` chunk's cost for truecolour: three 16-bit samples plus 12 bytes of framing. +const RGB_KEY_COST: usize = 6 + 12; + +/// Whether a colour key could possibly apply, before paying for the scan that looks for one. +/// +/// Needs an alpha channel to drop (`channels` even) and something for it to be carrying: an +/// all-opaque image is better served by the plain alpha *drop*, which costs no chunk at all. +/// +/// Split out, like [`keyed_size`], because [`write_reduced_or_native`] races the winning estimate +/// against the unreduced encoding — so perturbing this decision usually changes which candidate is +/// *offered* without changing the bytes that finally win, which makes it invisible from outside. +/// +/// [`write_reduced_or_native`]: crate::PngEncoder +fn may_have_colour_key(all_opaque: bool, channels: usize) -> bool { + !all_opaque && channels.is_multiple_of(2) +} + +/// Raw bytes a colour-key encoding costs: one sample per pixel for greyscale or three for +/// truecolour, plus the `tRNS` chunk that makes it lawful. +fn keyed_size(pixel_count: usize, all_gray: bool) -> usize { + if all_gray { + pixel_count + GREY_KEY_COST + } else { + pixel_count * 3 + RGB_KEY_COST + } +} + /// The colour that can stand for "transparent", if a `tRNS` colour key applies at all. /// /// Three conditions, all necessary (§11.3.2.1 gives a decoder exactly one transparent colour, not @@ -253,19 +283,10 @@ pub fn analyze8(pixels: &[u8], channels: usize) -> Option { } else { usize::MAX }; - // A colour key costs one `tRNS` chunk -- 6 bytes of payload for truecolour, 2 for greyscale, - // plus 12 of framing -- and buys the whole alpha channel. Only worth looking for when alpha is - // actually carrying something, which `all_opaque` already rules out. - let key = if all_opaque || !channels.is_multiple_of(2) { - None - } else { - colour_key(pixels, channels) - }; - let keyed_size = match key { - Some(_) if all_gray => pixel_count + 14, - Some(_) => pixel_count * 3 + 18, - None => usize::MAX, - }; + let key = may_have_colour_key(all_opaque, channels) + .then(|| colour_key(pixels, channels)) + .flatten(); + let keyed_size = key.map_or(usize::MAX, |_| keyed_size(pixel_count, all_gray)); let best = palette_size .min(gray_size) @@ -394,6 +415,33 @@ fn be_bytes(samples: impl Iterator) -> Vec { samples.flat_map(u16::to_be_bytes).collect() } +/// Orders the palette so the encoding costs less, returning the entries in their new order. +/// +/// Index order is not free: it decides the `tRNS` chunk's length, and it decides what the row +/// filters see, since a filtered index stream is the *difference* between neighbouring indices. +/// Two rules, in priority order: +/// +/// 1. **Transparent entries first**, least opaque first. `tRNS` may be shorter than `PLTE` and +/// every omitted entry defaults to opaque (§11.3.2.1), so gathering the transparent entries at +/// the front makes the trailing-opaque trim below cut as much as it possibly can. First- +/// appearance order left them scattered, so one late transparent entry pinned the whole chunk +/// to full length. +/// 2. **Then by luminance.** Neighbouring indices become neighbouring brightnesses, so an image +/// with smooth shading produces small index deltas rather than the arbitrary jumps +/// raster-scan discovery order gives — which is what `Sub` and `Paeth` are good at. +/// +/// Rec. 601 luma, integer, because this only has to *order* entries and never round-trips through +/// a pixel. The full modified-Zeng ordering oxipng uses is a further step (#482). +fn ordered_palette(palette: &[[u8; 4]]) -> Vec<[u8; 4]> { + let mut out = palette.to_vec(); + out.sort_by_key(|c| { + let luma = 299 * u32::from(c[0]) + 587 * u32::from(c[1]) + 114 * u32::from(c[2]); + // Opaque entries sort after every transparent one; within each group, by alpha then luma. + (u32::from(c[3] == 255), u32::from(c[3]), luma) + }); + out +} + /// Builds the indexed reduction from the collected palette. fn build_indexed( pixels: &[u8], @@ -401,14 +449,28 @@ fn build_indexed( palette: &[[u8; 4]], palette_index: &HashMap<[u8; 4], u8>, ) -> Reduced { + let ordered = ordered_palette(palette); + // Reindex through the new order. `palette_index` maps a colour to its *discovery* index, so + // this composes discovery -> colour -> final position. + let mut remap = vec![0u8; palette.len()]; + for (position, colour) in ordered.iter().enumerate() { + if let Some(&discovered) = palette_index.get(colour) { + remap[discovered as usize] = position as u8; + } + } let indices: Vec = pixels .chunks_exact(channels) - .map(|px| *palette_index.get(&pixel_key(px, channels)).unwrap_or(&0)) + .map(|px| { + let discovered = *palette_index.get(&pixel_key(px, channels)).unwrap_or(&0); + remap[discovered as usize] + }) .collect(); - let plte: Vec = palette.iter().flat_map(|c| [c[0], c[1], c[2]]).collect(); - let trns = if palette.iter().any(|c| c[3] != 255) { - let mut alphas: Vec = palette.iter().map(|c| c[3]).collect(); - // Trailing fully-opaque entries may be omitted (they default to opaque). + + let plte: Vec = ordered.iter().flat_map(|c| [c[0], c[1], c[2]]).collect(); + let trns = if ordered.iter().any(|c| c[3] != 255) { + let mut alphas: Vec = ordered.iter().map(|c| c[3]).collect(); + // Trailing fully-opaque entries may be omitted (they default to opaque). With the + // transparent entries gathered at the front this now trims everything after them. while alphas.len() > 1 && alphas.last() == Some(&255) { alphas.pop(); } @@ -417,7 +479,7 @@ fn build_indexed( None }; Reduced::Indexed { - depth: index_bit_depth(palette.len()), + depth: index_bit_depth(ordered.len()), indices, plte, trns, @@ -428,6 +490,31 @@ fn build_indexed( mod tests { use super::*; + #[test] + fn a_colour_key_is_only_possible_with_an_alpha_channel_carrying_something() { + assert!(may_have_colour_key(false, 4), "RGBA with transparency"); + assert!( + may_have_colour_key(false, 2), + "grey+alpha with transparency" + ); + // An all-opaque image drops the channel outright, which costs no chunk. + assert!(!may_have_colour_key(true, 4)); + // No alpha channel to drop in the first place. + assert!(!may_have_colour_key(false, 3)); + assert!(!may_have_colour_key(false, 1)); + } + + #[test] + fn the_keyed_cost_is_the_samples_plus_one_trns_chunk() { + // Greyscale: one byte per pixel, and a tRNS of one 16-bit sample plus 12 framing. + assert_eq!(keyed_size(100, true), 100 + 2 + 12); + // Truecolour: three bytes per pixel, and three 16-bit samples plus 12 framing. + assert_eq!(keyed_size(100, false), 300 + 6 + 12); + // The chunk is a flat cost -- it does not scale with the image. + assert_eq!(keyed_size(0, true), GREY_KEY_COST); + assert_eq!(keyed_size(0, false), RGB_KEY_COST); + } + #[test] fn cleaning_declines_when_there_is_nothing_invisible_to_clean() { // `None` rather than an unchanged copy: the encoder must be able to tell "no work" from From cb1c377daee568bc091de8a79e062b0db199abea Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 15:42:31 -0400 Subject: [PATCH 14/54] docs(png): refresh the efficiency tables after palette ordering The sprite row's cleaned figure moves 2619 -> 2235 and palette64's 715 -> 726, which is the trade the ordering commit measured. Axis 4 moves to partial: ordering landed, modified-Zeng and the caller-supplied palette path remain. Refs #224 --- crates/gamut-png/STATUS.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/gamut-png/STATUS.md b/crates/gamut-png/STATUS.md index d328cfbf..c7482ee5 100644 --- a/crates/gamut-png/STATUS.md +++ b/crates/gamut-png/STATUS.md @@ -71,8 +71,8 @@ Everything here is produced by `cargo bench -p gamut-png` and gated by | `photo_rgb8` | 196 608 | 29 885 | 20 293 | 20 293 | 27 467 | **−26.1%** | 2.477 | | `noise_rgb8` | 196 608 | 196 983 | 196 983 | 196 983 | 197 280 | −0.2% | 24.046 | | `grey_as_rgb8` | 196 608 | 721 | 370 | 370 | 566 | **−34.6%** | 0.045 | -| `palette64_rgba8` | 262 144 | 1 274 | 715 | 682 | 1 102 | **−35.1%** | 0.087 | -| `sprite_rgba8` | 262 144 | 4 181 | 3 729 | **2 619** | 3 889 | −4.1% | 0.455 | +| `palette64_rgba8` | 262 144 | 1 274 | 726 | 688 | 1 102 | **−34.1%** | 0.089 | +| `sprite_rgba8` | 262 144 | 4 181 | 3 729 | **2 235** | 3 889 | −4.1% | 0.455 | | `flat_rgba8` | 262 144 | 821 | 103 | 103 | 664 | **−84.5%** | 0.013 | | `tiny_rgb8` (16×16) | 768 | 136 | 135 | 135 | 138 | −2.2% | 4.219 | @@ -101,7 +101,7 @@ byte) plus removing a sixth redundant filter pass per scanline. | 1 | Filter selection | **partial** — per-line MinSumAbs plus six whole-image candidates each fully DEFLATEd. No entropy or bigram heuristic, no per-line trial deflate, no pruning, no two-tier trial. [#480] | | 2 | DEFLATE quality | **good, ~2% behind zopfli**, and honestly documented in `gamut-deflate`. Two contained wins remain: an 8-byte-at-a-time match compare, and `parse_dp`'s single-distance relaxation. [#478], [#479] | | 3 | Smallest lawful representation | **done** — grey, alpha-drop, ≤256 palette, 16→8, sub-byte, and a `tRNS` colour key for grey/truecolour. The key is worth ~7–9% on a contiguous transparent region, *not* the 25% the raw-byte arithmetic suggests: the alpha plane it removes is usually the most compressible plane in the image. | -| 4 | Palette optimization | **minimal** — trailing-opaque `tRNS` trim only. First-appearance order, no sorting; caller-supplied palettes get no dedupe or unused-entry removal. [#482] | +| 4 | Palette optimization | **partial** — trailing-opaque `tRNS` trim, plus ordering: transparent entries first (so that trim cuts as far as §11.3.2.1 allows) then by luma. Worth −14.7% on the sprite row against +1.5% on `palette64`. Modified-Zeng ordering and caller-supplied palette cleanup remain. [#482] | | 5 | Cleaning invisible data | **done** — `with_transparent_cleanup`, opt-in. Worth 30% on the sprite row, and it is what makes a colour key reachable at all on a source whose invisible pixels carry different unseen colours. | | 6 | Metadata hygiene | **no policy** — the encoder emits exactly what the caller set, and `gamut convert` drops metadata on the PNG path. [#483] | | 7 | Interlacing | **correctly none.** Adam7 costs 5–20%; out of scope by declaration. | From 4fa858b9a5b8684051cd14a45b0e006b81f30466 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 15:59:30 -0400 Subject: [PATCH 15/54] feat(png): entropy and bigram filter heuristics Sum-of-absolutes asks "are these bytes small?". DEFLATE asks "are these bytes repetitive?". Those are different questions, and a row alternating 0 and 200 answers the first badly and the second beautifully -- which is why oxipng dropped libpng's MinSum from every preset except its cheapest and its most expensive. That is a preset table, not published byte counts, so gamut measured it on its own corpus. IDAT bytes at `Level::Best`, each heuristic alone: input MinSumAbs Entropy Bigrams winner gradient_rgb8 2215 2215 1505 Bigrams photo_rgb8 25364 22427 19513 Bigrams noise_rgb8 196890 196890 196890 tie grey_as_rgb8 475 506 506 MinSumAbs palette64_rgba8 990 899 770 Bigrams sprite_rgba8 3672 3857 4062 MinSumAbs flat_rgba8 573 573 605 MinSumAbs tiny_rgb8 79 79 62 Bigrams Bigrams wins four rows by 22-32%; MinSumAbs wins three by 5-6%. Neither dominates and the margins run the wrong way to drop either, so both are in the brute-force set -- which is also the shape of oxipng's own presets. **Entropy is never the unique winner, and that is recorded as a negative result rather than quietly merged.** It beats MinSumAbs on the photographic and palette rows but loses to Bigrams on both, and ties MinSumAbs elsewhere. The brute-force set resolves by taking the smallest, so a candidate dominated everywhere costs a full filter pass and a full DEFLATE for nothing. It is not in that set. It stays selectable, because eight images is a corpus and not a proof, and `docs/benchmarking.md` asks for the negative result to be written down so nobody re-derives it. End to end, with Bigrams in the brute-force set: row before after gradient_rgb8 2272 1562 -31.2% (vs libpng-9: -5.1% -> -34.7%) tiny_rgb8 135 119 -11.9% (vs libpng-9: -2.2% -> -13.8%) photo_rgb8 20293 19570 -3.6% (vs libpng-9: -26.1% -> -28.8%) The scorers share one `Scratch` allocated per image, not per scanline: the bigram set is 8 KiB of bitset and rebuilding it per row would dominate the very measurement it exists to make cheap. A test pins that the scratch does not leak state between rows, because a stale one would silently score every row after the first against the previous row's data. `tests/backends.rs`'s `rgb8_best_bruteforce` golden is re-captured: Bigrams wins on that fixture and takes its IDAT from 36 bytes to 21. That pin exists to prove the *codec-abi seam* is inert, not to freeze the encoder, so the comment there now records the re-capture and why -- an encoder change making output *larger* would look identical at that assertion and would be a regression. Refs #224, #480. --- crates/gamut-png/STATUS.md | 36 +++++- crates/gamut-png/benches/encode.rs | 40 ++++++ crates/gamut-png/src/encoder.rs | 11 +- crates/gamut-png/src/filter.rs | 188 +++++++++++++++++++++++++++-- crates/gamut-png/tests/backends.rs | 10 +- 5 files changed, 271 insertions(+), 14 deletions(-) diff --git a/crates/gamut-png/STATUS.md b/crates/gamut-png/STATUS.md index c7482ee5..da174e4a 100644 --- a/crates/gamut-png/STATUS.md +++ b/crates/gamut-png/STATUS.md @@ -67,19 +67,45 @@ Everything here is produced by `cargo bench -p gamut-png` and gated by | input | raw | default | best | +clean | libpng-9 | best/lp9 | bpp | | --- | --- | --- | --- | --- | --- | --- | --- | -| `gradient_rgb8` | 196 608 | 2 831 | 2 272 | 2 272 | 2 393 | **−5.1%** | 0.277 | -| `photo_rgb8` | 196 608 | 29 885 | 20 293 | 20 293 | 27 467 | **−26.1%** | 2.477 | +| `gradient_rgb8` | 196 608 | 2 831 | 1 562 | 1 562 | 2 393 | **−34.7%** | 0.191 | +| `photo_rgb8` | 196 608 | 29 885 | 19 570 | 19 570 | 27 467 | **−28.8%** | 2.389 | | `noise_rgb8` | 196 608 | 196 983 | 196 983 | 196 983 | 197 280 | −0.2% | 24.046 | -| `grey_as_rgb8` | 196 608 | 721 | 370 | 370 | 566 | **−34.6%** | 0.045 | +| `grey_as_rgb8` | 196 608 | 721 | 368 | 368 | 566 | **−35.0%** | 0.045 | | `palette64_rgba8` | 262 144 | 1 274 | 726 | 688 | 1 102 | **−34.1%** | 0.089 | | `sprite_rgba8` | 262 144 | 4 181 | 3 729 | **2 235** | 3 889 | −4.1% | 0.455 | | `flat_rgba8` | 262 144 | 821 | 103 | 103 | 664 | **−84.5%** | 0.013 | -| `tiny_rgb8` (16×16) | 768 | 136 | 135 | 135 | 138 | −2.2% | 4.219 | +| `tiny_rgb8` (16×16) | 768 | 136 | 119 | 119 | 138 | **−13.8%** | 3.719 | gamut is smaller than libpng-9 on every row. The margin is thin where no reduction applies (`gradient`, `tiny`) or nothing is compressible (`noise`), and large where a lawful representation change is available that libpng does not attempt. +### Filter heuristics (issue #480) + +`BruteForce` tries every whole-image strategy and keeps the smallest, so the size table above +cannot say *which* heuristic earned the win. IDAT bytes at `Level::Best`, each heuristic alone: + +| input | MinSumAbs | Entropy | Bigrams | winner | +| --- | --- | --- | --- | --- | +| `gradient_rgb8` | 2 215 | 2 215 | **1 505** | Bigrams | +| `photo_rgb8` | 25 364 | 22 427 | **19 513** | Bigrams | +| `noise_rgb8` | 196 890 | 196 890 | 196 890 | tie | +| `grey_as_rgb8` | **475** | 506 | 506 | MinSumAbs | +| `palette64_rgba8` | 990 | 899 | **770** | Bigrams | +| `sprite_rgba8` | **3 672** | 3 857 | 4 062 | MinSumAbs | +| `flat_rgba8` | **573** | 573 | 605 | MinSumAbs | +| `tiny_rgb8` | 79 | 79 | **62** | Bigrams | + +**Bigrams wins four rows by 22–32%; MinSumAbs wins three by 5–6%.** Both stay in the brute-force +set: neither dominates, and the margins run the wrong way to drop either. That matches oxipng +keeping MinSum at `-o 0`/`-o 6` while its default preset leads with Bigrams. + +**Entropy is never the unique winner**, and that is a recorded negative result. It beats MinSumAbs +on the photographic and palette rows but loses to Bigrams on both, and ties MinSumAbs elsewhere. +Since the brute-force set is resolved by taking the smallest, a candidate dominated everywhere +costs a full filter pass and a full DEFLATE for nothing — so it is not in that set. It stays +selectable: eight images is a corpus, not a proof. + ### Throughput | stage | before | after | | @@ -98,7 +124,7 @@ byte) plus removing a sixth redundant filter pass per scanline. | # | Axis | State | | --- | --- | --- | -| 1 | Filter selection | **partial** — per-line MinSumAbs plus six whole-image candidates each fully DEFLATEd. No entropy or bigram heuristic, no per-line trial deflate, no pruning, no two-tier trial. [#480] | +| 1 | Filter selection | **partial** — MinSumAbs, Entropy and Bigrams per line, plus seven whole-image candidates each fully DEFLATEd. Bigrams is worth 22–32% where it wins (see above). Still missing: per-line trial deflate, `AtomicMin` pruning, and a two-tier cheap-trial codec. [#480] | | 2 | DEFLATE quality | **good, ~2% behind zopfli**, and honestly documented in `gamut-deflate`. Two contained wins remain: an 8-byte-at-a-time match compare, and `parse_dp`'s single-distance relaxation. [#478], [#479] | | 3 | Smallest lawful representation | **done** — grey, alpha-drop, ≤256 palette, 16→8, sub-byte, and a `tRNS` colour key for grey/truecolour. The key is worth ~7–9% on a contiguous transparent region, *not* the 25% the raw-byte arithmetic suggests: the alpha plane it removes is usually the most compressible plane in the image. | | 4 | Palette optimization | **partial** — trailing-opaque `tRNS` trim, plus ordering: transparent entries first (so that trim cuts as far as §11.3.2.1 allows) then by luma. Worth −14.7% on the sprite row against +1.5% on `palette64`. Modified-Zeng ordering and caller-supplied palette cleanup remain. [#482] | diff --git a/crates/gamut-png/benches/encode.rs b/crates/gamut-png/benches/encode.rs index b4abf0db..b1e72417 100644 --- a/crates/gamut-png/benches/encode.rs +++ b/crates/gamut-png/benches/encode.rs @@ -31,6 +31,7 @@ use corpus::{ fn main() { print_size_table(); print_stage_table(); + print_heuristic_table(); divan::main(); } @@ -238,6 +239,45 @@ fn print_stage_table() { } } +/// Prints what each per-scanline filter heuristic is worth on its own. +/// +/// `BruteForce` tries them all and keeps the smallest, so the aggregate table above cannot say +/// *which* one earned the win — and that is exactly the question issue #480 asks. oxipng's +/// evidence for demoting libpng's MinSum out of its default preset is a preset table, not +/// published byte counts, so gamut has to measure it on its own corpus. +fn print_heuristic_table() { + println!( + "\nper-scanline filter heuristic, IDAT bytes at Level::Best (lower is better):\n\n\ + {:<17} {:>10} {:>10} {:>10} winner", + "input", "MinSumAbs", "Entropy", "Bigrams" + ); + for case in corpus() { + let of = |filter| { + let png = case.gamut(Level::Best, filter, false); + deconstruct(&png) + .expect("gamut's own output deconstructs") + .idat_compressed + }; + let (msa, ent, big) = ( + of(FilterStrategy::MinSumAbs), + of(FilterStrategy::MinEntropy), + of(FilterStrategy::MinBigrams), + ); + let best = msa.min(ent).min(big); + let winner = if best == msa { + "MinSumAbs" + } else if best == ent { + "Entropy" + } else { + "Bigrams" + }; + println!( + "{:<17} {:>10} {:>10} {:>10} {winner}", + case.name, msa, ent, big + ); + } +} + fn case_named(name: &str) -> Case { corpus() .into_iter() diff --git a/crates/gamut-png/src/encoder.rs b/crates/gamut-png/src/encoder.rs index 04aa4910..03168b52 100644 --- a/crates/gamut-png/src/encoder.rs +++ b/crates/gamut-png/src/encoder.rs @@ -22,13 +22,22 @@ use crate::{ihdr, pack}; const IDAT_MAX: usize = 1 << 16; /// Whole-image filter strategies tried by [`FilterStrategy::BruteForce`]. -const BRUTE_FORCE_STRATEGIES: [FilterStrategy; 6] = [ +/// +/// [`FilterStrategy::MinEntropy`] is deliberately **not** here, and that was measured rather than +/// assumed. Across the benchmark corpus it is never the unique winner: it beats `MinSumAbs` on the +/// photographic and palette rows but loses to `MinBigrams` on both, and ties `MinSumAbs` elsewhere. +/// Since this list is resolved by taking the smallest result, a candidate that is dominated +/// everywhere costs a full filter pass and a full DEFLATE for nothing. It stays available as a +/// caller-selectable strategy — the corpus is eight images, not a proof — but it does not earn a +/// slot here. See `STATUS.md`'s heuristic table. +const BRUTE_FORCE_STRATEGIES: [FilterStrategy; 7] = [ FilterStrategy::None, FilterStrategy::Fixed(FilterType::Sub), FilterStrategy::Fixed(FilterType::Up), FilterStrategy::Fixed(FilterType::Average), FilterStrategy::Fixed(FilterType::Paeth), FilterStrategy::MinSumAbs, + FilterStrategy::MinBigrams, ]; /// A reusable PNG encoder. diff --git a/crates/gamut-png/src/filter.rs b/crates/gamut-png/src/filter.rs index 9bae4b9c..d6e5a902 100644 --- a/crates/gamut-png/src/filter.rs +++ b/crates/gamut-png/src/filter.rs @@ -31,6 +31,17 @@ pub enum FilterStrategy { /// Per scanline, pick the filter minimising the sum of absolute residuals — the standard /// libpng heuristic. A good size/speed balance and the default. MinSumAbs, + /// Per scanline, pick the filter whose residuals have the lowest Shannon entropy. + /// + /// Sum-of-absolutes asks "are these bytes small?"; entropy asks "are these bytes *repetitive*?" + /// — which is the question DEFLATE actually answers. A row of alternating 0 and 200 scores + /// badly under `MinSumAbs` and beautifully under this. + MinEntropy, + /// Per scanline, pick the filter producing the fewest distinct byte bigrams. + /// + /// A cheaper proxy for the same idea one order up: LZ77 matches runs, not single bytes, so + /// counting distinct adjacent pairs approximates how much of the row it can back-reference. + MinBigrams, /// Encode the whole image under several filter strategies, DEFLATE each, and keep the smallest. /// Pairs with [`Level::Best`](gamut_deflate::Level::Best) for maximum compression; slowest. BruteForce, @@ -173,6 +184,76 @@ fn sum_abs(filtered: &[u8]) -> u64 { .sum() } +/// How a candidate row is judged. Lower is better for all three, so they are interchangeable in +/// [`choose_by`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Score { + /// Sum of absolute residuals, bytes read as signed magnitudes (libpng's heuristic). + SumAbs, + /// Shannon entropy of the byte histogram. + Entropy, + /// Count of distinct adjacent byte pairs. + Bigrams, +} + +/// Scratch a scorer needs, allocated once per image rather than per scanline. +/// +/// The bigram set is 8 KiB of bitset; rebuilding it per row would dominate the measurement it is +/// supposed to make cheap. +struct Scratch { + /// Byte histogram for [`Score::Entropy`]. + histogram: [u32; 256], + /// One bit per (previous, current) byte pair for [`Score::Bigrams`]. + bigrams: Vec, +} + +impl Scratch { + fn new() -> Self { + Self { + histogram: [0; 256], + bigrams: vec![0; 1 << 10], + } + } +} + +/// Scores a filtered row; lower is better in every variant, so candidates compare directly. +fn score(kind: Score, filtered: &[u8], scratch: &mut Scratch) -> u64 { + match kind { + Score::SumAbs => sum_abs(filtered), + Score::Entropy => { + scratch.histogram.fill(0); + for &b in filtered { + scratch.histogram[b as usize] += 1; + } + // Shannon entropy over a fixed-length row is `n·log2(n) − Σ c·log2(c)`, and `n` is the + // same for every candidate, so the first term is a constant that cannot change the + // ranking. Minimising entropy is therefore maximising `Σ c·log2(c)` — negated here so + // that lower stays better, and scaled to integers so the comparison is exact and the + // choice reproducible run to run. + let weighted: f64 = scratch + .histogram + .iter() + .filter(|&&c| c > 1) + .map(|&c| f64::from(c) * f64::from(c).log2()) + .sum(); + u64::MAX - (weighted * 256.0) as u64 + } + Score::Bigrams => { + scratch.bigrams.fill(0); + let mut distinct = 0u64; + for pair in filtered.windows(2) { + let index = (usize::from(pair[0]) << 8) | usize::from(pair[1]); + let (word, bit) = (index >> 6, index & 63); + if scratch.bigrams[word] & (1 << bit) == 0 { + scratch.bigrams[word] |= 1 << bit; + distinct += 1; + } + } + distinct + } + } +} + /// Filters every scanline of `samples` (row-major, `row_bytes` per row) per `strategy`, producing /// the filter-prefixed byte stream that gets compressed: a filter-type byte then the filtered row, /// for each scanline. `bpp` is the filter stride (bytes per pixel, ≥1). @@ -188,17 +269,24 @@ pub fn filter_image( let mut prev = zero_row.as_slice(); let mut scratch = Vec::with_capacity(row_bytes); let mut chosen = Vec::with_capacity(row_bytes); + let mut aux = Scratch::new(); + // The per-scanline heuristics differ only in how they score a candidate. BruteForce is + // resolved to concrete strategies by the encoder; if it reaches here, fall back to MinSumAbs. + let adaptive = match strategy { + FilterStrategy::MinSumAbs | FilterStrategy::BruteForce => Some(Score::SumAbs), + FilterStrategy::MinEntropy => Some(Score::Entropy), + FilterStrategy::MinBigrams => Some(Score::Bigrams), + FilterStrategy::None | FilterStrategy::Fixed(_) => None, + }; for y in 0..height { let cur = &samples[y * row_bytes..(y + 1) * row_bytes]; - match strategy { - // BruteForce is resolved to concrete strategies by the encoder; if it reaches here, - // fall back to the per-scanline heuristic. - FilterStrategy::MinSumAbs | FilterStrategy::BruteForce => { - let filter = choose_min_sum_abs(cur, prev, bpp, &mut scratch, &mut chosen); + match adaptive { + Some(kind) => { + let filter = choose_by(kind, cur, prev, bpp, &mut scratch, &mut chosen, &mut aux); out.push(filter as u8); out.extend_from_slice(&chosen); } - FilterStrategy::None | FilterStrategy::Fixed(_) => { + None => { let filter = match strategy { FilterStrategy::Fixed(f) => f, _ => FilterType::None, @@ -220,12 +308,44 @@ pub fn filter_image( /// over the row instead of six: the caller would otherwise re-run [`filter_row`] for the filter /// just chosen, having already computed exactly those bytes and thrown them away. Keeping them /// costs one `memcpy` per improvement, against a full filter pass per scanline. +#[cfg_attr( + not(feature = "test-support"), + allow( + dead_code, + reason = "the benchmark stage seam's entry point; see crate::stages" + ) +)] pub fn choose_min_sum_abs( cur: &[u8], prev: &[u8], bpp: usize, scratch: &mut Vec, best_bytes: &mut Vec, +) -> FilterType { + choose_by( + Score::SumAbs, + cur, + prev, + bpp, + scratch, + best_bytes, + &mut Scratch::new(), + ) +} + +/// Tries all five filters and keeps the one `kind` ranks lowest, leaving its bytes in +/// `best_bytes`. +/// +/// The first minimum wins, so a tie resolves to the earlier filter in None/Sub/Up/Average/Paeth +/// order — deterministic, which the byte-reproducibility contract depends on. +fn choose_by( + kind: Score, + cur: &[u8], + prev: &[u8], + bpp: usize, + scratch: &mut Vec, + best_bytes: &mut Vec, + aux: &mut Scratch, ) -> FilterType { let mut best = FilterType::None; let mut best_score = u64::MAX; @@ -237,7 +357,7 @@ pub fn choose_min_sum_abs( FilterType::Paeth, ] { filter_row(filter, cur, prev, bpp, scratch); - let score = sum_abs(scratch); + let score = score(kind, scratch, aux); if score < best_score { best_score = score; best = filter; @@ -334,6 +454,60 @@ mod tests { assert_eq!(wide2, [128, 190]); // 1+floor(255/2)=128, then 255+floor((128+255)/2)=255+191 wraps to 190 } + /// A row that is *large* but *repetitive*: alternating 0 and 200 under `Sub`. + /// + /// This is the case the two new heuristics exist for. Sum-of-absolutes asks "are these bytes + /// small?" and rates it terribly; entropy and bigrams ask "are these bytes repetitive?", which + /// is the question DEFLATE actually answers. + #[test] + fn entropy_and_bigrams_prefer_repetition_where_sum_abs_prefers_smallness() { + let repetitive: Vec = (0..64).map(|i| if i % 2 == 0 { 0 } else { 200 }).collect(); + let varied: Vec = (0..64u8).map(|i| i / 8).collect(); + let mut aux = Scratch::new(); + + // Sum-of-absolutes: the varied row is far "smaller" and wins. + assert!( + score(Score::SumAbs, &varied, &mut aux) < score(Score::SumAbs, &repetitive, &mut aux) + ); + // Entropy and bigrams: the repetitive row has two symbols and one alternating pair, and + // wins by a mile. + assert!( + score(Score::Entropy, &repetitive, &mut aux) < score(Score::Entropy, &varied, &mut aux) + ); + assert!( + score(Score::Bigrams, &repetitive, &mut aux) < score(Score::Bigrams, &varied, &mut aux) + ); + } + + #[test] + fn the_bigram_score_counts_distinct_adjacent_pairs() { + let mut aux = Scratch::new(); + // (1,2), (2,1), (1,2), (2,1) -> two distinct pairs, however long the run. + assert_eq!(score(Score::Bigrams, &[1, 2, 1, 2, 1], &mut aux), 2); + // A constant row has exactly one. + assert_eq!(score(Score::Bigrams, &[7, 7, 7, 7], &mut aux), 1); + // Every pair distinct. + assert_eq!(score(Score::Bigrams, &[1, 2, 3, 4], &mut aux), 3); + // Fewer than two bytes has no pairs at all. + assert_eq!(score(Score::Bigrams, &[9], &mut aux), 0); + assert_eq!(score(Score::Bigrams, &[], &mut aux), 0); + } + + #[test] + fn the_scratch_is_reusable_across_rows() { + // The histogram and bigram set are allocated once per image, so a stale one would silently + // score the wrong thing on every row after the first. + let mut aux = Scratch::new(); + let first = score(Score::Bigrams, &[1, 2, 3, 4], &mut aux); + let second = score(Score::Bigrams, &[7, 7, 7, 7], &mut aux); + assert_eq!(first, 3); + assert_eq!(second, 1, "the previous row's pairs must not carry over"); + + let a = score(Score::Entropy, &[0, 0, 0, 0], &mut aux); + let b = score(Score::Entropy, &[0, 1, 2, 3], &mut aux); + assert!(a < b, "a constant row must stay the lower-entropy one"); + } + #[test] fn min_sum_abs_prefers_flat_residuals() { // A horizontal gradient (each pixel = previous + k) filters to a constant under Sub, which diff --git a/crates/gamut-png/tests/backends.rs b/crates/gamut-png/tests/backends.rs index 6c649123..de966162 100644 --- a/crates/gamut-png/tests/backends.rs +++ b/crates/gamut-png/tests/backends.rs @@ -24,6 +24,14 @@ use gamut_png::{ /// Bytes captured from the encoder **before** the seam existed. Pushing no backend must reproduce /// them exactly: the registry is inert by construction, not merely "close enough". +/// +/// This pins the *seam*, not the encoder — so a deliberate encoding improvement re-captures the +/// affected row, and the change is recorded here rather than being absorbed silently: +/// +/// * `rgb8_best_bruteforce`, issue #224: `FilterStrategy::MinBigrams` joined +/// `BRUTE_FORCE_STRATEGIES` and wins on this fixture, taking the IDAT from 36 bytes to 21. The +/// gate catching that is the point of it — an encoder change that made output *larger* would +/// look identical here, and would be a regression. const GOLDEN: [(&str, &str); 11] = [ ( "gray8", @@ -59,7 +67,7 @@ const GOLDEN: [(&str, &str); 11] = [ ), ( "rgb8_best_bruteforce", - "89504e470d0a1a0a0000000d49484452000000080000000808020000004b6d29dc000000244944415478da636160e713c5065856ac58418404828357079a14761d081e5ea3b0ca0100921322178646d81f0000000049454e44ae426082", + "89504e470d0a1a0a0000000d49484452000000080000000808020000004b6d29dc000000154944415478da636460e713c5069856e0008353020008cb701e6f73d8bc0000000049454e44ae426082", ), ( "rgb8_fast", From 319c345b173e3c87df575ff44962090e7d444c58 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 19:07:52 -0400 Subject: [PATCH 16/54] fix(png): emit a filtered row when every filter candidate ties `choose_by` seeded `best_score` with `u64::MAX` and improved on a strict `<`, so a row whose five candidates all scored `u64::MAX` left `best_bytes` untouched. `filter_image` hoists that buffer out of the row loop, so such a row was emitted under a filter byte of 0 carrying the *previous* row's residuals -- or, on the first row, nothing at all. `Score::Entropy` reached that sentinel whenever no byte value repeated in the filtered row, which is ordinary for narrow images. A 2x1 Gray8 `[1, 3]` encoded to a PNG whose IDAT is shorter than its image; a 2x2 `[0, 0, 0, 1]` encoded to a structurally valid PNG decoding to `[0, 0, 0, 0]` -- silent corruption, no error anywhere. Two independent fixes, because one is a class and the other an instance. `best_score` becomes `Option`, so "nothing chosen yet" is unrepresentable as a score and the first candidate is taken whatever any scorer returns; a future scorer cannot reintroduce this. And the entropy score is restated as `sum c*log2(n/c)`, the quantity its doc already claimed, which is non-negative and bounded by `8n*256` -- so it can no longer collide with a sentinel at all. The tie-break is unchanged: the only comparison is still a strict `<` over candidates 2..5, and candidate 1 is `FilterType::None`, first in the documented None/Sub/Up/Average/Paeth order. No pinned bytes move, because `sum_abs` and `Bigrams` are bounded far below `u64::MAX` and so always wrote on their first candidate already -- the two paths are bit-identical for every strategy in `BRUTE_FORCE_STRATEGIES`, and `MinEntropy` is not in that set. `tests/oracle.rs` gains the end-to-end sweep whose absence hid this: `MinEntropy` was scored by unit tests but never encoded with. --- crates/gamut-png/src/filter.rs | 83 +++++++++++++++++++++++++++----- crates/gamut-png/tests/oracle.rs | 33 +++++++++++++ 2 files changed, 103 insertions(+), 13 deletions(-) diff --git a/crates/gamut-png/src/filter.rs b/crates/gamut-png/src/filter.rs index d6e5a902..cddfd151 100644 --- a/crates/gamut-png/src/filter.rs +++ b/crates/gamut-png/src/filter.rs @@ -36,6 +36,13 @@ pub enum FilterStrategy { /// Sum-of-absolutes asks "are these bytes small?"; entropy asks "are these bytes *repetitive*?" /// — which is the question DEFLATE actually answers. A row of alternating 0 and 200 scores /// badly under `MinSumAbs` and beautifully under this. + /// + /// The only strategy that scores in floating point. `f64::log2` is not required to be + /// correctly rounded, so this strategy's output is reproducible on a machine but not + /// guaranteed bit-identical across libm implementations — which is why it is absent from + /// [`BRUTE_FORCE_STRATEGIES`](crate::PngEncoder), keeping the default and `BruteForce` paths + /// integer-only and their output byte-exact everywhere. It never uniquely won a corpus row + /// (`STATUS.md`), so it is offered rather than chosen. MinEntropy, /// Per scanline, pick the filter producing the fewest distinct byte bigrams. /// @@ -225,18 +232,28 @@ fn score(kind: Score, filtered: &[u8], scratch: &mut Scratch) -> u64 { for &b in filtered { scratch.histogram[b as usize] += 1; } - // Shannon entropy over a fixed-length row is `n·log2(n) − Σ c·log2(c)`, and `n` is the - // same for every candidate, so the first term is a constant that cannot change the - // ranking. Minimising entropy is therefore maximising `Σ c·log2(c)` — negated here so - // that lower stays better, and scaled to integers so the comparison is exact and the - // choice reproducible run to run. - let weighted: f64 = scratch + // Shannon entropy times the row length, `Σ c·log2(n/c)`, in 1/256ths of a bit so the + // comparison is integer-exact and the choice reproducible run to run. + // + // Stated this way every term is non-negative (`c ≤ n`) and the whole score is bounded + // by `8n·256` — a byte alphabet carries at most 8 bits — so lower is better directly, + // rather than by complementing against `u64::MAX`. That matters beyond tidiness: a + // score that can *reach* `u64::MAX` is a score that can collide with a sentinel, and + // this one did. + // + // Equivalent to the `n·log2(n) − Σ c·log2(c)` form: `n` is constant across a row's + // five candidates, so it cannot change the ranking either way. The `c == 1` terms + // contribute `0` there and `1·log2(n)` here, which is why the filter below is `c > 0` + // — a zero count is excluded because `0·log2(n/0)` is not a number, not because it + // contributes nothing. + let n = filtered.len() as f64; + let bits: f64 = scratch .histogram .iter() - .filter(|&&c| c > 1) - .map(|&c| f64::from(c) * f64::from(c).log2()) + .filter(|&&c| c > 0) + .map(|&c| f64::from(c) * (n / f64::from(c)).log2()) .sum(); - u64::MAX - (weighted * 256.0) as u64 + (bits * 256.0) as u64 } Score::Bigrams => { scratch.bigrams.fill(0); @@ -348,7 +365,12 @@ fn choose_by( aux: &mut Scratch, ) -> FilterType { let mut best = FilterType::None; - let mut best_score = u64::MAX; + // `None`, not a sentinel score. Seeding with `u64::MAX` and improving on a strict `<` leaves + // `best_bytes` unwritten when every candidate scores `u64::MAX` — and `filter_image` reuses + // that buffer across scanlines, so the row would be emitted with its predecessor's residuals + // under a filter byte of 0. `Option` makes "nothing chosen yet" unrepresentable as a score, so + // the first candidate is always taken whatever any scorer returns. + let mut best_score: Option = None; for filter in [ FilterType::None, FilterType::Sub, @@ -357,9 +379,9 @@ fn choose_by( FilterType::Paeth, ] { filter_row(filter, cur, prev, bpp, scratch); - let score = score(kind, scratch, aux); - if score < best_score { - best_score = score; + let candidate = score(kind, scratch, aux); + if best_score.is_none_or(|best| candidate < best) { + best_score = Some(candidate); best = filter; best_bytes.clear(); best_bytes.extend_from_slice(scratch); @@ -508,6 +530,41 @@ mod tests { assert!(a < b, "a constant row must stay the lower-entropy one"); } + #[test] + fn a_universal_score_tie_keeps_the_first_filters_bytes() { + // Every candidate for this row has all-distinct bytes, so under a scorer that ranks by + // repetition they all score identically. `choose_by` must still emit the first candidate's + // bytes: before the `Option` seed it emitted none at all, and `filter_image` produced a + // one-byte stream for a two-byte row -- a PNG whose IDAT is shorter than its image. + assert_eq!( + filter_image(FilterStrategy::MinEntropy, &[1, 3], 2, 1), + [FilterType::None as u8, 1, 3] + ); + } + + #[test] + fn a_tied_row_does_not_reuse_the_previous_rows_residuals() { + // The companion failure to the one above, and the dangerous one: `filter_image` hoists the + // chosen-bytes buffer out of the row loop, so a row that chose nothing re-emitted its + // predecessor's residuals under a filter byte of 0 -- a structurally valid PNG carrying + // the wrong pixels, with no error anywhere. + assert_eq!( + filter_image(FilterStrategy::MinEntropy, &[0, 0, 0, 1], 2, 1), + [FilterType::None as u8, 0, 0, FilterType::None as u8, 0, 1] + ); + } + + #[test] + fn the_entropy_scale_separates_rows_closer_than_one_bit() { + // The scale is what makes the score integer-exact: these two rows carry 8.000 and 8.490 + // bits, which both floor to 8. Only multiplying by 256 before the cast keeps them apart, + // so this is the assertion that a `+ 256.0` or `/ 256.0` scale cannot satisfy. + let mut aux = Scratch::new(); + let even = score(Score::Entropy, &[0, 0, 0, 0, 1, 1, 1, 1], &mut aux); + let skewed = score(Score::Entropy, &[0, 0, 0, 0, 0, 0, 1, 2], &mut aux); + assert!(even < skewed, "{even} < {skewed}"); + } + #[test] fn min_sum_abs_prefers_flat_residuals() { // A horizontal gradient (each pixel = previous + k) filters to a constant under Sub, which diff --git a/crates/gamut-png/tests/oracle.rs b/crates/gamut-png/tests/oracle.rs index 34653c40..772bb41b 100644 --- a/crates/gamut-png/tests/oracle.rs +++ b/crates/gamut-png/tests/oracle.rs @@ -695,3 +695,36 @@ fn solid_image_round_trips() { let dec = libpng_oracle::decode(&png); assert_eq!(dec.pixels, src); } + +#[test] +fn every_filter_strategy_survives_the_libpng_round_trip() { + // The end-to-end pin whose absence hid a silent-corruption defect: `MinEntropy` was scored but + // never encoded with, so nothing noticed that a row whose candidates all tied emitted its + // predecessor's residuals. Sweeping the whole enum means a new strategy cannot land unproven. + // + // Deliberately narrow: 3x7 is the smallest corpus size whose rows are short enough for an + // all-distinct-bytes tie, which is exactly the case that used to break. + let (w, h) = (3, 7); + let src = rgb_pattern(w, h); + let dims = Dimensions::new(w, h).unwrap(); + for strategy in [ + FilterStrategy::None, + FilterStrategy::Fixed(FilterType::None), + FilterStrategy::Fixed(FilterType::Sub), + FilterStrategy::Fixed(FilterType::Up), + FilterStrategy::Fixed(FilterType::Average), + FilterStrategy::Fixed(FilterType::Paeth), + FilterStrategy::MinSumAbs, + FilterStrategy::MinEntropy, + FilterStrategy::MinBigrams, + FilterStrategy::BruteForce, + ] { + let mut png = Vec::new(); + PngEncoder::new() + .with_filter(strategy) + .encode_image(ImageRef::::new(&src, dims).unwrap(), &mut png) + .expect("encode"); + let dec = libpng_oracle::decode(&png); + assert_eq!(dec.pixels, src, "{strategy:?} did not round-trip"); + } +} From ea4a9e239db5aeeeff796e8fef16780ace285769 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 19:08:09 -0400 Subject: [PATCH 17/54] refactor(png): read the bigram index as one big-endian pair `(a << 8) | b` over two `u8`s is spelling out `u16::from_be_bytes`, and it costs two operators that carry no meaning of their own. One of them has no behavioural variant at all: the low byte of `a << 8` is zero, so `|` and `^` compute the same index, and no test can ever tell them apart. `.cargo/mutants.toml` would accept a line-scoped exclusion with that argument written out. Restructuring is better and the file already prefers it -- `deconstruct.rs` twice shapes code so an equivalent mutant is never generated rather than excluding one after the fact. Reading the pair as the big-endian `u16` it is leaves no operator to mutate. The bigram vectors gain the case none of them covered: (1,3), (3,2), (2,3) is three distinct pairs over two distinct second bytes, so an index that dropped the high byte would report two. Every existing vector happens to have as many pairs as second bytes. --- crates/gamut-png/src/filter.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/gamut-png/src/filter.rs b/crates/gamut-png/src/filter.rs index cddfd151..2982517b 100644 --- a/crates/gamut-png/src/filter.rs +++ b/crates/gamut-png/src/filter.rs @@ -259,7 +259,11 @@ fn score(kind: Score, filtered: &[u8], scratch: &mut Scratch) -> u64 { scratch.bigrams.fill(0); let mut distinct = 0u64; for pair in filtered.windows(2) { - let index = (usize::from(pair[0]) << 8) | usize::from(pair[1]); + // The pair *is* a big-endian `u16`, so read it as one. Spelling it `a << 8 | b` + // costs two operators that carry no meaning of their own -- one of which has no + // behavioural variant at all, since the low byte of `a << 8` is zero and `|` is + // therefore indistinguishable from `^`. + let index = usize::from(u16::from_be_bytes([pair[0], pair[1]])); let (word, bit) = (index >> 6, index & 63); if scratch.bigrams[word] & (1 << bit) == 0 { scratch.bigrams[word] |= 1 << bit; @@ -513,6 +517,10 @@ mod tests { // Fewer than two bytes has no pairs at all. assert_eq!(score(Score::Bigrams, &[9], &mut aux), 0); assert_eq!(score(Score::Bigrams, &[], &mut aux), 0); + // Distinct *pairs*, not distinct second bytes: (1,3), (3,2), (2,3) is three pairs over two + // distinct second bytes, so an index that dropped the high byte would report two. Every + // vector above happens to have as many pairs as second bytes, so none of them can tell. + assert_eq!(score(Score::Bigrams, &[1, 3, 2, 3], &mut aux), 3); } #[test] From 73e9c0b5f7ec0cf7f37e25ab6c6a41400cb36931 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 19:12:45 -0400 Subject: [PATCH 18/54] refactor(png): make the colour-key arms total instead of unreachable `analyze8` reached its colour-key branch through `key.expect(...)` -- the only `expect` outside `#[cfg(test)]` in the crate's `src/`, which the house rule forbids in library code paths. Fold the option into the guard with a let-chain, as the palette scan at the top of the function already does. Behaviour is identical: when no key was found `keyed_size` is `usize::MAX`, and `best` has already been proven smaller than `input_size`, so `best == keyed_size` could never hold. `colour_key` carried the same shape one level down. Its `any_transparent` flag was assigned in exactly the arm that assigns `candidate`, so `!any_transparent` was a spelling of `candidate.is_none()` that the following `candidate?` discharges again -- an unkillable mutant in a file `.cargo/mutants.toml` does not exclude. Drop the flag and record in the doc why condition 2 needs no check of its own, including the caller gate (`may_have_colour_key` requires `!all_opaque`) that makes the `?` itself unreachable in practice. --- crates/gamut-png/src/reduce.rs | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/crates/gamut-png/src/reduce.rs b/crates/gamut-png/src/reduce.rs index 8cffeafc..cb6072ae 100644 --- a/crates/gamut-png/src/reduce.rs +++ b/crates/gamut-png/src/reduce.rs @@ -181,33 +181,33 @@ fn keyed_size(pixel_count: usize, all_gray: bool) -> usize { /// needs. Without it, a source whose transparent pixels carry different unseen colours has no key /// available and keeps its alpha channel. /// +/// Condition 2 needs no check of its own: `candidate` is assigned in the `alpha == 0` arm and +/// nowhere else, so "some pixel is transparent" is exactly `candidate.is_some()` and the +/// `candidate?` below discharges it. Callers reach here only through +/// [`may_have_colour_key`], which already requires `!all_opaque`, and any alpha that is neither 0 +/// nor 255 returns early — so in practice the `?` never fires; it is the total spelling of a +/// condition the caller gate has already established. +/// /// Two passes rather than one: the candidate is not known until the first transparent pixel is /// seen, so proving no *earlier* opaque pixel used it needs a second look. The second pass only /// runs when the first has already established a candidate. fn colour_key(pixels: &[u8], channels: usize) -> Option<[u8; 4]> { debug_assert!(channels == 2 || channels == 4); let mut candidate: Option<[u8; 4]> = None; - let mut any_transparent = false; for px in pixels.chunks_exact(channels) { let key = pixel_key(px, channels); match key[3] { - 0 => { - any_transparent = true; - match candidate { - // A second transparent colour: no single key can stand for both. - Some(seen) if seen[..3] != key[..3] => return None, - Some(_) => {} - None => candidate = Some(key), - } - } + 0 => match candidate { + // A second transparent colour: no single key can stand for both. + Some(seen) if seen[..3] != key[..3] => return None, + Some(_) => {} + None => candidate = Some(key), + }, 255 => {} // Partial transparency cannot be expressed as a colour key. _ => return None, } } - if !any_transparent { - return None; - } let candidate = candidate?; // The key must name a colour nothing visible uses. let collides = pixels.chunks_exact(channels).any(|px| { @@ -314,8 +314,9 @@ pub fn analyze8(pixels: &[u8], channels: usize) -> Option { out.push(key[3]); } Some(Reduced::GrayAlpha8(out)) - } else if best == keyed_size { - let key = key.expect("keyed_size is only finite when a key was found"); + } else if let Some(key) = key + && best == keyed_size + { if all_gray { Some(Reduced::GrayKeyed { samples: pixels From 0c27e290a07abbacaaf3c3f7ffd7b74af8bf1634 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 19:14:18 -0400 Subject: [PATCH 19/54] test(png): separate palette ordering from discovery order `ordered_palette` was untested as a function: every palette fixture in the crate happens to have discovery order equal to sorted order, so none of them could tell it from the identity. The three Rec. 601 weights survived mutation to additions for exactly that reason. Pin the luma order on a five-entry fixture chosen so collapsing any one weight to an addition returns a different sequence, and tabulate the four columns in the doc comment so the choice of entries is auditable. Pin rule 1 separately, through `build_indexed`, on a palette whose transparent entry is discovered last -- the case first-appearance order gets wrong. In discovery order the `tRNS` alphas are `[255, 255, 0]` and the trailing-opaque trim cannot shorten them at all; sorted transparent-first they are `[0, 255, 255]` and the trim cuts two of three. --- crates/gamut-png/src/reduce.rs | 68 ++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/crates/gamut-png/src/reduce.rs b/crates/gamut-png/src/reduce.rs index cb6072ae..ad3504f9 100644 --- a/crates/gamut-png/src/reduce.rs +++ b/crates/gamut-png/src/reduce.rs @@ -611,6 +611,74 @@ mod tests { assert!(analyze8(&rgb, 3).is_none()); } + /// Rec. 601 luma is the *only* thing separating these five opaque entries -- same alpha, so + /// the first two sort-key components tie -- and the fixture is chosen so collapsing any one of + /// the three weights from a multiply to an add returns a different order: + /// + /// | entry | `299*c0 + 587*c1 + 114*c2` | `299 +` | `587 +` | `114 +` | + /// | --- | --- | --- | --- | --- | + /// | `[255, 0, 0]` | 76 245 | 554 | 76 832 | 76 359 | + /// | `[0, 100, 0]` | 58 700 | 58 999 | 687 | 58 814 | + /// | `[0, 130, 0]` | 76 310 | 76 609 | 717 | 76 424 | + /// | `[0, 0, 255]` | 29 070 | 29 369 | 29 657 | 369 | + /// | `[0, 49, 0]` | 28 763 | 29 062 | 636 | 28 877 | + /// + /// Every other palette fixture in the crate happens to have discovery order equal to sorted + /// order, so none of them can tell [`ordered_palette`] from the identity, let alone tell one + /// weight from another. The input order below is deliberately not the expected order. + #[test] + fn the_palette_orders_by_rec_601_luma() { + let palette = [ + [255, 0, 0, 255], + [0, 100, 0, 255], + [0, 130, 0, 255], + [0, 0, 255, 255], + [0, 49, 0, 255], + ]; + assert_eq!( + ordered_palette(&palette), + vec![ + [0, 49, 0, 255], + [0, 0, 255, 255], + [0, 100, 0, 255], + [255, 0, 0, 255], + [0, 130, 0, 255], + ] + ); + } + + /// Rule 1 of [`ordered_palette`] earning its keep, end to end through [`build_indexed`]. + /// + /// The transparent entry is discovered *last* here: the raster scan meets opaque white, then + /// opaque red, and only then the invisible pixels. In discovery order the `tRNS` alphas would + /// be `[255, 255, 0]`, which the trailing-opaque trim cannot shorten at all -- one late + /// transparent entry pins the chunk to full length. Sorting transparent-first makes them + /// `[0, 255, 255]`, and the trim cuts two of the three. + #[test] + fn a_late_transparent_entry_moves_to_index_zero_and_shortens_trns() { + let mut rgba = Vec::new(); + for i in 0..80u32 { + if i % 2 == 0 { + rgba.extend_from_slice(&[255, 255, 255, 255]); // opaque white + } else { + rgba.extend_from_slice(&[200, 10, 10, 255]); // opaque red + } + } + rgba.extend_from_slice(&[0, 0, 0, 0].repeat(40)); // invisible, discovered last + + match analyze8(&rgba, 4) { + Some(Reduced::Indexed { plte, trns, .. }) => { + assert_eq!( + plte, + vec![0, 0, 0, 200, 10, 10, 255, 255, 255], + "transparent first, then opaque by luma" + ); + assert_eq!(trns, Some(vec![0]), "the trim reaches every opaque entry"); + } + _ => panic!("expected Indexed"), + } + } + #[test] fn palette_with_transparency_emits_trns() { let rgba = [ From 934a76fa28cd6f99405e9f3620f8cfcff9519b40 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 19:15:09 -0400 Subject: [PATCH 20/54] perf(png): index the chunk tally by type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A PNG chunk type is four unvalidated bytes and the deconstruct walk never drops a chunk, so a hostile file chooses how many *distinct* types it carries: one per 12-byte chunk. Accumulating the per-type totals with a linear scan over the types seen so far was therefore quadratic in the file length, reachable from `gamut inspect` on an untrusted file — 4.8 MB of empty chunks took 40.9 s. A private `ChunkTally` keeps a `HashMap<[u8; 4], usize>` beside the stats vector, so each chunk costs O(1) and the public `Vec` keeps the first-appearance order it documents. The map is dropped at the end of the walk and never surfaced; `ChunkStats` stays `Copy` and `#[non_exhaustive]`. Hashing attacker-chosen keys is safe only because the default hasher is SipHash-1-3 with a per-process seed, so that is recorded on the type: a faster unseeded hasher would reopen the blow-up by a different route. `PngReport::chunk` stays a linear scan — O(distinct types) per call, not quadratic — and now documents that cost, and that summarising every type means iterating `chunks` once rather than calling it per type. The regression test asserts a self-calibrating ratio rather than a wall-clock ceiling, which would be flaky under `llvm-cov` and parallel test binaries: two files of equal byte length and equal chunk count, one distinct type per chunk against one repeated type, deconstructed back to back in one process. Measured 3–5x with the index and 1488x without it (18.0 s against 12.1 ms), so the 20x bound has ~4x of headroom above the fix and ~75x below the defect. --- crates/gamut-png/src/deconstruct.rs | 83 +++++++++++++++++++++------- crates/gamut-png/tests/accounting.rs | 80 +++++++++++++++++++++++++++ 2 files changed, 144 insertions(+), 19 deletions(-) diff --git a/crates/gamut-png/src/deconstruct.rs b/crates/gamut-png/src/deconstruct.rs index b7cca440..556f05d9 100644 --- a/crates/gamut-png/src/deconstruct.rs +++ b/crates/gamut-png/src/deconstruct.rs @@ -25,6 +25,7 @@ //! bad signature, no first chunk, a first chunk that is not IHDR, or an unparsable IHDR — fails. use core::ops::Range; +use std::collections::HashMap; use gamut_core::{Error, Result}; @@ -256,6 +257,11 @@ impl PngReport { } /// The stats for one chunk type, if the file carries it. + /// + /// A linear scan of [`chunks`](Self::chunks), so it costs O(distinct chunk types) per call — + /// bounded by the *types* the file carries, not by its chunk count. Looking up a handful of + /// types is what this is for; to summarise every type, iterate [`chunks`](Self::chunks) once + /// rather than calling this per type. #[must_use] pub fn chunk(&self, chunk_type: &[u8; 4]) -> Option { self.chunks @@ -265,6 +271,58 @@ impl PngReport { } } +/// Accumulates the per-chunk-type totals of one walk, in time linear in the chunk count. +/// +/// A chunk type is four **unvalidated** bytes — [`crate::chunk`] reads them straight out of the +/// file and the walk never drops a chunk — so a hostile 12-byte-per-chunk file carries one +/// *distinct* type per chunk. Accumulating with a linear `find` over the types seen so far is +/// then quadratic in the file length: 4.8 MB of empty chunks took 40.9 s. The index makes each +/// chunk O(1), and `stats` keeps the first-appearance order [`PngReport::chunks`] documents. +/// +/// The keys are attacker-chosen, which is safe **because** [`HashMap`]'s default hasher is +/// SipHash-1-3 seeded per process: collisions cannot be precomputed against it. Do not swap in a +/// faster unseeded hasher (`FxHash`, `AHash` without a random seed) — that would reopen the +/// quadratic blow-up this type exists to close, by a different route. +struct ChunkTally { + /// One entry per distinct type, in first-appearance order. + stats: Vec, + /// Type → its index in `stats`. Dropped at the end of the walk; never surfaced. + index: HashMap<[u8; 4], usize>, +} + +impl ChunkTally { + /// An empty tally. + fn new() -> Self { + Self { + stats: Vec::new(), + index: HashMap::new(), + } + } + + /// Adds one chunk of `chunk_type` carrying `payload_len` payload bytes. + fn record(&mut self, chunk_type: [u8; 4], payload_len: usize) { + match self.index.get(&chunk_type) { + Some(&at) => { + self.stats[at].count += 1; + self.stats[at].payload_bytes += payload_len; + } + None => { + self.index.insert(chunk_type, self.stats.len()); + self.stats.push(ChunkStats { + chunk_type, + count: 1, + payload_bytes: payload_len, + }); + } + } + } + + /// The accumulated totals, in first-appearance order. + fn into_stats(self) -> Vec { + self.stats + } +} + /// The largest filtered stream this walk will inflate to count filter choices. Matches the /// decoder's own default image budget, so a report never allocates more than a decode would. const MAX_FILTERED_BYTES: usize = 64 << 20; @@ -310,10 +368,10 @@ pub fn deconstruct(png: &[u8]) -> Result { interlaced: native.interlaced, }; - let mut chunks: Vec = Vec::new(); + let mut tally = ChunkTally::new(); let mut idat = Vec::new(); let mut saw_iend = false; - let push = |segments: &mut Vec, chunks: &mut Vec, chunk: &RawChunk| { + let push = |segments: &mut Vec, tally: &mut ChunkTally, chunk: &RawChunk| { segments.push(Segment { range: chunk.range.clone(), kind: SegmentKind::Chunk { @@ -322,22 +380,9 @@ pub fn deconstruct(png: &[u8]) -> Result { crc_ok: chunk.crc_ok, }, }); - match chunks - .iter_mut() - .find(|stats| stats.chunk_type == chunk.chunk_type) - { - Some(stats) => { - stats.count += 1; - stats.payload_bytes += chunk.data.len(); - } - None => chunks.push(ChunkStats { - chunk_type: chunk.chunk_type, - count: 1, - payload_bytes: chunk.data.len(), - }), - } + tally.record(chunk.chunk_type, chunk.data.len()); }; - push(&mut segments, &mut chunks, &first); + push(&mut segments, &mut tally, &first); loop { match reader.next_chunk() { @@ -347,7 +392,7 @@ pub fn deconstruct(png: &[u8]) -> Result { idat.extend_from_slice(chunk.data); } let is_iend = &chunk.chunk_type == b"IEND"; - push(&mut segments, &mut chunks, &chunk); + push(&mut segments, &mut tally, &chunk); if is_iend { saw_iend = true; break; @@ -389,7 +434,7 @@ pub fn deconstruct(png: &[u8]) -> Result { file_len: png.len(), header, segments, - chunks, + chunks: tally.into_stats(), idat_compressed: idat.len(), filtered_len, passes, diff --git a/crates/gamut-png/tests/accounting.rs b/crates/gamut-png/tests/accounting.rs index 0124bcd4..422593d0 100644 --- a/crates/gamut-png/tests/accounting.rs +++ b/crates/gamut-png/tests/accounting.rs @@ -9,6 +9,8 @@ mod common; +use std::time::Instant; + use gamut_core::{Dimensions, EncodeImage, ImageRef, Rgb8, Rgba8}; use gamut_png::{ ChunkStats, FilterStrategy, FilterType, PngEncoder, Segment, SegmentKind, deconstruct, @@ -170,6 +172,84 @@ fn repeated_chunk_types_accumulate_count_and_payload() { /// A stream large enough to split across several IDAT chunks: the same accumulation, on the path /// that actually produces it in production rather than a hand-built file. +/// A synthetic chunk type for the quadratic regression fixture below: four lowercase letters, so +/// it is ancillary, private, and can never collide with `IHDR`, `IDAT` or `IEND`. 26⁴ = 456 976 +/// distinct types, comfortably more than the fixture uses. +fn synthetic_type(i: usize) -> [u8; 4] { + [ + b'a' + (i % 26) as u8, + b'a' + (i / 26 % 26) as u8, + b'a' + (i / 676 % 26) as u8, + b'a' + (i / 17_576 % 26) as u8, + ] +} + +/// Deconstruction must not slow down when every chunk type in the file is distinct. +/// +/// A chunk type is four unvalidated bytes and the walk never drops a chunk, so an attacker +/// chooses how many *distinct* types a file carries — one per 12-byte chunk, if they like. +/// Accumulating the per-type totals with a linear scan made this quadratic in the file length +/// (measured: 4.8 MB → 40.9 s), reachable from `gamut inspect` on an untrusted file. +/// +/// The claim asserted is not "fast" — an absolute wall-clock ceiling is flaky under `llvm-cov` +/// and parallel test binaries — but "the cost does not depend on how many distinct types the file +/// carries". The two halves are byte-for-byte the same length and carry the same number of +/// chunks, differing only in how many types those chunks use, and they run back to back in one +/// process under one load, so each calibrates the other. The fixed path measures ~2–4×; the +/// defect is three orders of magnitude worse, leaving ~5× of headroom above the fix and ~50× +/// below the defect. The structural assertions below mean it is not purely a timing test. +#[test] +fn the_chunk_tally_does_not_slow_down_when_every_type_is_distinct() { + /// Empty chunks between IHDR and IEND: 12 bytes each, so ~3.1 MB per half. + const CHUNKS: usize = 262_144; + + let build = |distinct: bool| { + let mut framed = Vec::with_capacity(CHUNKS + 2); + framed.push(common::chunk(b"IHDR", &common::ihdr_payload(1, 1, 8, 2, 0))); + framed.extend( + (0..CHUNKS).map(|i| common::chunk(&synthetic_type(if distinct { i } else { 0 }), &[])), + ); + framed.push(common::chunk(b"IEND", &[])); + common::png_from_chunks(&framed) + }; + let repeated = build(false); + let distinct = build(true); + assert_eq!( + repeated.len(), + distinct.len(), + "the two halves must be the same length, or the ratio compares two workloads" + ); + + let started = Instant::now(); + let repeated_report = deconstruct(&repeated).expect("deconstruct"); + let repeated_elapsed = started.elapsed(); + let started = Instant::now(); + let distinct_report = deconstruct(&distinct).expect("deconstruct"); + let distinct_elapsed = started.elapsed(); + + assert_eq!( + distinct_report.chunks.len(), + CHUNKS + 2, + "IHDR, one entry per distinct type, IEND" + ); + assert!( + distinct_report.chunks.iter().all(|stats| stats.count == 1), + "every synthetic type appears exactly once" + ); + assert_eq!( + repeated_report.chunks.len(), + 3, + "IHDR, the one repeated type, IEND" + ); + assert_eq!(repeated_report.chunks[1].count, CHUNKS); + + assert!( + distinct_elapsed < 20 * repeated_elapsed, + "distinct types cost {distinct_elapsed:?} against {repeated_elapsed:?} for the same \ + bytes with one type: the tally is scaling with the number of distinct types" + ); +} + #[test] fn a_multi_idat_encode_accumulates_every_idat() { // Incompressible, so the zlib stream stays far above the 64 KiB per-chunk cap. From 25b1a14349ad501f17463c730f7eab9f7d9f7aea Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 19:15:39 -0400 Subject: [PATCH 21/54] feat(png): clean invisible colour on the 16-bit paths too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `with_transparent_cleanup` documented "no effect on an image with no fully transparent pixel, or on a layout with no alpha channel", but `cleaned_samples` was only reached from `EncodeImage` and `EncodeImage`. `Rgba16` and `GrayAlpha16` carry an alpha channel and can carry fully transparent pixels, so a caller enabling the knob on a 16-bit sprite got the documented behaviour's opposite: silently nothing. `reduce::clean_transparent` cannot serve those layouts — it reads one-byte samples on a one-byte stride, whereas a 16-bit pixel is invisible only when its whole alpha sample is zero, and clearing a colour sample must clear all sixteen bits. Add `clean_transparent16`, its `u16` twin, beside the encoder. Working on the samples rather than on the big-endian bytes `encode_16bit` serialises keeps the ordering identical to the 8-bit paths: cleanup runs first, so `reduce::analyze16` sees the collapsed invisible pixels. `encode_16bit` therefore takes dimensions plus samples instead of the `ImageRef`, so the alpha layouts can hand it a cleaned buffer. The inline tests pin the two things the byte-wise reading would get wrong: an alpha sample of `0x0001` is visible (its high byte is zero), and every cleared colour sample is cleared in both bytes. `tests/transparent_cleanup.rs` adds the end-to-end halves for both layouts against libpng — `decode` rather than `decode_rgba8`, which would scale 16-bit samples down to 8 and hide exactly that low byte — plus the size claim and the byte-identical no-op on an opaque image. Correct the doc to describe what is now true. --- crates/gamut-png/src/encoder.rs | 137 +++++++++++-- crates/gamut-png/tests/transparent_cleanup.rs | 190 +++++++++++++++++- 2 files changed, 304 insertions(+), 23 deletions(-) diff --git a/crates/gamut-png/src/encoder.rs b/crates/gamut-png/src/encoder.rs index 03168b52..38e3d096 100644 --- a/crates/gamut-png/src/encoder.rs +++ b/crates/gamut-png/src/encoder.rs @@ -143,8 +143,10 @@ impl PngEncoder { /// this one is only reversible in what you can see. /// /// Worth enabling for sprites, icons and UI assets, where invisible colour noise is common - /// and can cost real bytes. No effect on an image with no fully transparent pixel, or on a - /// layout with no alpha channel. + /// and can cost real bytes. It applies to every layout that carries an alpha channel, at both + /// 8 and 16 bits per sample; a 16-bit pixel counts as invisible when its whole alpha sample is + /// zero, and all sixteen bits of each colour sample are cleared. No effect on an image with no + /// fully transparent pixel, or on a layout with no alpha channel. #[must_use] pub fn with_transparent_cleanup(mut self, enabled: bool) -> Self { self.clean_transparent = enabled; @@ -378,15 +380,25 @@ impl PngEncoder { .flatten() } + /// The 16-bit twin of [`cleaned_samples`](Self::cleaned_samples): the cleaned samples, or + /// `None` to use the caller's buffer unchanged. + fn cleaned_samples16(&self, samples: &[u16], channels: usize) -> Option> { + self.clean_transparent + .then(|| clean_transparent16(samples, channels)) + .flatten() + } + /// Encodes a 16-bit-per-sample image, serialising samples big-endian (PNG's network byte order). - fn encode_16bit>( + /// + /// Takes the samples rather than the [`ImageRef`] so the alpha layouts can hand over a cleaned + /// buffer (see [`cleaned_samples16`](Self::cleaned_samples16)). + fn encode_16bit( &self, - image: ImageRef<'_, P>, + dims: Dimensions, + samples: &[u16], color: ColorType, out: &mut Vec, ) -> Result { - let dims = image.dimensions(); - let samples = image.as_samples(); let mut bytes = Vec::with_capacity(samples.len() * 2); for &sample in samples { bytes.extend_from_slice(&sample.to_be_bytes()); @@ -633,6 +645,35 @@ fn prefers_native(native_len: usize, palette_len: usize) -> bool { native_len < palette_len } +/// Zeroes the colour samples of every fully transparent pixel in a 16-bit interleaved buffer, +/// returning `None` when there is nothing to do (no alpha channel, or no fully transparent pixel) +/// so the caller can keep borrowing its own samples. +/// +/// The 8-bit twin is `reduce::clean_transparent`, which cannot serve here: it reads one-byte +/// samples with a one-byte stride, whereas a 16-bit pixel is invisible only when its *whole* alpha +/// sample is zero (both bytes of the stored big-endian pair), and clearing a colour sample must +/// clear all sixteen bits. Working on the `u16` samples rather than on the big-endian bytes +/// `PngEncoder::encode_16bit` emits keeps the ordering identical to the 8-bit paths — cleanup runs +/// first, so `reduce::analyze16` gets to see the collapsed invisible pixels. +fn clean_transparent16(samples: &[u16], channels: usize) -> Option> { + debug_assert!((1..=4).contains(&channels)); + if !channels.is_multiple_of(2) { + return None; // no alpha channel + } + let colour = channels - 1; // colour samples are everything before alpha + if !samples.chunks_exact(channels).any(|px| px[colour] == 0) { + return None; + } + + let mut out = samples.to_vec(); + for px in out.chunks_exact_mut(channels) { + if px[colour] == 0 { + px[..colour].fill(0); + } + } + Some(out) +} + /// Writes the zlib datastream as one or more consecutive IDAT chunks. fn write_idat(out: &mut Vec, zlib_stream: &[u8]) { if zlib_stream.is_empty() { @@ -766,62 +807,70 @@ impl EncodeImage for PngEncoder { } impl EncodeImage for PngEncoder { fn encode_image(&self, image: ImageRef<'_, Gray16>, out: &mut Vec) -> Result { + let (dims, samples) = (image.dimensions(), image.as_samples()); if self.auto_reduce - && let Some(reduced) = reduce::analyze16(image.as_samples(), 1) + && let Some(reduced) = reduce::analyze16(samples, 1) { return self.write_reduced_or_native( - image.dimensions(), + dims, reduced, - |o| self.encode_16bit(image, ColorType::Grayscale, o), + |o| self.encode_16bit(dims, samples, ColorType::Grayscale, o), out, ); } - self.encode_16bit(image, ColorType::Grayscale, out) + self.encode_16bit(dims, samples, ColorType::Grayscale, out) } } impl EncodeImage for PngEncoder { fn encode_image(&self, image: ImageRef<'_, Rgb16>, out: &mut Vec) -> Result { + let (dims, samples) = (image.dimensions(), image.as_samples()); if self.auto_reduce - && let Some(reduced) = reduce::analyze16(image.as_samples(), 3) + && let Some(reduced) = reduce::analyze16(samples, 3) { return self.write_reduced_or_native( - image.dimensions(), + dims, reduced, - |o| self.encode_16bit(image, ColorType::Truecolor, o), + |o| self.encode_16bit(dims, samples, ColorType::Truecolor, o), out, ); } - self.encode_16bit(image, ColorType::Truecolor, out) + self.encode_16bit(dims, samples, ColorType::Truecolor, out) } } impl EncodeImage for PngEncoder { fn encode_image(&self, image: ImageRef<'_, Rgba16>, out: &mut Vec) -> Result { + let cleaned = self.cleaned_samples16(image.as_samples(), 4); + let samples = cleaned.as_deref().unwrap_or_else(|| image.as_samples()); + let dims = image.dimensions(); if self.auto_reduce - && let Some(reduced) = reduce::analyze16(image.as_samples(), 4) + && let Some(reduced) = reduce::analyze16(samples, 4) { return self.write_reduced_or_native( - image.dimensions(), + dims, reduced, - |o| self.encode_16bit(image, ColorType::TruecolorAlpha, o), + |o| self.encode_16bit(dims, samples, ColorType::TruecolorAlpha, o), out, ); } - self.encode_16bit(image, ColorType::TruecolorAlpha, out) + self.encode_16bit(dims, samples, ColorType::TruecolorAlpha, out) } } impl EncodeImage for PngEncoder { fn encode_image(&self, image: ImageRef<'_, GrayAlpha16>, out: &mut Vec) -> Result { + let cleaned = self.cleaned_samples16(image.as_samples(), 2); + let samples = cleaned.as_deref().unwrap_or_else(|| image.as_samples()); + let dims = image.dimensions(); if self.auto_reduce - && let Some(reduced) = reduce::analyze16(image.as_samples(), 2) + && let Some(reduced) = reduce::analyze16(samples, 2) { return self.write_reduced_or_native( - image.dimensions(), + dims, reduced, - |o| self.encode_16bit(image, ColorType::GrayscaleAlpha, o), + |o| self.encode_16bit(dims, samples, ColorType::GrayscaleAlpha, o), out, ); } - self.encode_16bit(image, ColorType::GrayscaleAlpha, out) + self.encode_16bit(dims, samples, ColorType::GrayscaleAlpha, out) } } @@ -918,4 +967,48 @@ mod tests { let idats = out.windows(4).filter(|w| *w == b"IDAT").count(); assert!(idats >= 3, "expected multiple IDAT chunks, found {idats}"); } + + #[test] + fn cleaning_16_bit_pixels_needs_the_whole_alpha_sample_to_be_zero() { + // The byte-wise twin would read the big-endian pair `0x0001` as a zero high byte and + // wrongly call this pixel invisible; at `u16` width it is visible and must be untouched. + // The third pixel is the genuinely invisible one, and all three of its colour samples — + // both bytes of each — must be cleared. + let src: [u16; 12] = [ + 0x1234, 0x5678, 0x9ABC, 0xFFFF, // visible + 0x1111, 0x2222, 0x3333, 0x0001, // alpha 1: barely visible, must stay + 0x4444, 0x5555, 0x6666, 0x0000, // invisible: colour must go + ]; + let cleaned = clean_transparent16(&src, 4).expect("there is a transparent pixel"); + assert_eq!( + cleaned, + vec![ + 0x1234, 0x5678, 0x9ABC, 0xFFFF, // + 0x1111, 0x2222, 0x3333, 0x0001, // + 0, 0, 0, 0, + ] + ); + } + + #[test] + fn cleaning_16_bit_grey_alpha_zeroes_only_the_grey_sample() { + let src: [u16; 6] = [0xC800, 0xFFFF, 0x6F00, 0x0000, 0x5A00, 0x0001]; + let cleaned = clean_transparent16(&src, 2).expect("there is a transparent pixel"); + assert_eq!(cleaned, vec![0xC800, 0xFFFF, 0, 0, 0x5A00, 0x0001]); + } + + #[test] + fn cleaning_16_bit_declines_when_there_is_nothing_to_clean() { + let opaque: [u16; 8] = [1, 2, 3, 0xFFFF, 4, 5, 6, 0xFFFF]; + assert!( + clean_transparent16(&opaque, 4).is_none(), + "no fully transparent pixel" + ); + + // Odd channel counts have no alpha sample, so a zero there is a colour, not transparency. + let grey: [u16; 3] = [0, 7, 9]; + assert!(clean_transparent16(&grey, 1).is_none(), "no alpha channel"); + let rgb: [u16; 6] = [1, 2, 0, 4, 5, 6]; + assert!(clean_transparent16(&rgb, 3).is_none(), "no alpha channel"); + } } diff --git a/crates/gamut-png/tests/transparent_cleanup.rs b/crates/gamut-png/tests/transparent_cleanup.rs index ba8ac66c..3eda1a3b 100644 --- a/crates/gamut-png/tests/transparent_cleanup.rs +++ b/crates/gamut-png/tests/transparent_cleanup.rs @@ -9,7 +9,7 @@ mod common; -use gamut_core::{Dimensions, EncodeImage, ImageRef, Rgba8}; +use gamut_core::{Dimensions, EncodeImage, GrayAlpha16, ImageRef, Rgba8, Rgba16}; use gamut_png::{FilterStrategy, Level, PngEncoder}; const SIDE: u32 = 64; @@ -119,3 +119,191 @@ fn cleanup_is_off_by_default() { .expect("encode"); assert_eq!(default_out, encode(&src, false, false)); } + +// --- 16-bit layouts ------------------------------------------------------------------------- +// +// `Rgba16` and `GrayAlpha16` carry an alpha channel and can carry fully transparent pixels, so +// the knob's documented behaviour applies to them too. The oracle here is `libpng_oracle::decode` +// rather than `decode_rgba8`: the simplified reader would scale 16-bit samples down to 8 bits and +// hide exactly the low byte a byte-wise cleanup would get wrong. + +/// A 16-bit sprite: an opaque disc over fully transparent pixels whose colour samples vary in +/// *both* bytes, so a cleanup that only cleared high bytes would leave compressible noise behind. +fn sprite_rgba16(side: u32) -> Vec { + let mut buf = vec![0u16; (side * side * 4) as usize]; + let r2 = (i64::from(side) * i64::from(side)) / 9; + for y in 0..side { + for x in 0..side { + let i = ((y * side + x) * 4) as usize; + let cx = i64::from(x) - i64::from(side) / 2; + let cy = i64::from(y) - i64::from(side) / 2; + if cx * cx + cy * cy < r2 { + buf[i] = u16::from((x ^ y) as u8) * 257; + buf[i + 1] = 0x4040; + buf[i + 2] = 0xC0C0; + buf[i + 3] = u16::MAX; + } else { + // Invisible, and deliberately not constant in either byte of any sample. + buf[i] = (x as u16).wrapping_mul(1103); + buf[i + 1] = (y as u16).wrapping_mul(2749); + buf[i + 2] = ((x ^ y) as u16).wrapping_mul(7919); + buf[i + 3] = 0; + } + } + } + buf +} + +/// The [`sprite_rgba16`] shape in two channels: an opaque grey band over invisible grey noise. +fn sprite_gray_alpha16(side: u32) -> Vec { + let mut buf = vec![0u16; (side * side * 2) as usize]; + for y in 0..side { + for x in 0..side { + let i = ((y * side + x) * 2) as usize; + if x % 8 < 5 { + buf[i] = u16::from((y % 32) as u8) * 2048; + buf[i + 1] = u16::MAX; + } else { + buf[i] = (x as u16).wrapping_mul(6151) ^ (y as u16).wrapping_mul(769); + buf[i + 1] = 0; + } + } + } + buf +} + +fn encode_rgba16(samples: &[u16], cleanup: bool) -> Vec { + let dims = Dimensions::new(SIDE, SIDE).expect("valid dimensions"); + let image = ImageRef::::new(samples, dims).expect("buffer matches dimensions"); + let mut out = Vec::new(); + PngEncoder::new() + .with_compression(Level::Best) + .with_filter(FilterStrategy::BruteForce) + .with_transparent_cleanup(cleanup) + .encode_image(image, &mut out) + .expect("encode"); + out +} + +fn encode_gray_alpha16(samples: &[u16], cleanup: bool) -> Vec { + let dims = Dimensions::new(SIDE, SIDE).expect("valid dimensions"); + let image = ImageRef::::new(samples, dims).expect("buffer matches dimensions"); + let mut out = Vec::new(); + PngEncoder::new() + .with_compression(Level::Best) + .with_filter(FilterStrategy::BruteForce) + .with_transparent_cleanup(cleanup) + .encode_image(image, &mut out) + .expect("encode"); + out +} + +/// The decoded 16-bit samples, as big-endian pairs reassembled into `u16`. +fn decode16(png: &[u8], channels: usize) -> Vec { + let decoded = libpng_oracle::decode(png); + assert_eq!(decoded.bit_depth, 16, "the 16-bit path must stay 16-bit"); + assert_eq!( + decoded.pixels.len(), + (SIDE * SIDE) as usize * channels * 2, + "unexpected layout" + ); + decoded + .pixels + .as_chunks::<2>() + .0 + .iter() + .map(|&p| u16::from_be_bytes(p)) + .collect() +} + +#[test] +fn every_visible_rgba16_pixel_survives_cleanup_unchanged() { + let src = sprite_rgba16(SIDE); + let plain = decode16(&encode_rgba16(&src, false), 4); + let cleaned = decode16(&encode_rgba16(&src, true), 4); + + let mut invisible_changed = 0usize; + let (plain_px, _) = plain.as_chunks::<4>(); + let (clean_px, _) = cleaned.as_chunks::<4>(); + for (i, (a, b)) in plain_px.iter().zip(clean_px).enumerate() { + assert_eq!(a[3], b[3], "pixel {i}: alpha must never change"); + if a[3] == 0 { + assert_eq!( + &b[..3], + &[0, 0, 0], + "pixel {i}: invisible colour must be zeroed" + ); + if a[..3] != b[..3] { + invisible_changed += 1; + } + } else { + assert_eq!(a, b, "pixel {i} is visible and must be sample-identical"); + } + } + assert!( + invisible_changed > 0, + "the fixture must actually exercise the cleanup" + ); +} + +#[test] +fn every_visible_gray_alpha16_pixel_survives_cleanup_unchanged() { + let src = sprite_gray_alpha16(SIDE); + let plain = decode16(&encode_gray_alpha16(&src, false), 2); + let cleaned = decode16(&encode_gray_alpha16(&src, true), 2); + + let mut invisible_changed = 0usize; + let (plain_px, _) = plain.as_chunks::<2>(); + let (clean_px, _) = cleaned.as_chunks::<2>(); + for (i, (a, b)) in plain_px.iter().zip(clean_px).enumerate() { + assert_eq!(a[1], b[1], "pixel {i}: alpha must never change"); + if a[1] == 0 { + assert_eq!(b[0], 0, "pixel {i}: invisible grey must be zeroed"); + if a[0] != b[0] { + invisible_changed += 1; + } + } else { + assert_eq!(a, b, "pixel {i} is visible and must be sample-identical"); + } + } + assert!( + invisible_changed > 0, + "the fixture must actually exercise the cleanup" + ); +} + +#[test] +fn cleanup_shrinks_a_16_bit_image_with_invisible_noise() { + // The knob's whole justification is that invisible noise costs real bytes, and it costs twice + // as many of them per sample at 16 bits. Both fixtures carry it, so on both the cleaned + // encoding must come out strictly smaller — the same claim + // `cleanup_shrinks_an_image_with_invisible_colour_noise` makes for `Rgba8`. + let rgba = sprite_rgba16(SIDE); + assert!( + encode_rgba16(&rgba, true).len() < encode_rgba16(&rgba, false).len(), + "rgba16: {} vs {}", + encode_rgba16(&rgba, true).len(), + encode_rgba16(&rgba, false).len() + ); + let grey = sprite_gray_alpha16(SIDE); + assert!( + encode_gray_alpha16(&grey, true).len() < encode_gray_alpha16(&grey, false).len(), + "gray-alpha16: {} vs {}", + encode_gray_alpha16(&grey, true).len(), + encode_gray_alpha16(&grey, false).len() + ); +} + +#[test] +fn cleanup_is_inert_on_a_fully_opaque_16_bit_image() { + // No fully transparent pixel means the pass must not even copy the buffer: byte-identical + // output, not merely equal length. + let opaque: Vec = (0..(SIDE * SIDE)) + .flat_map(|i| [i as u16, 0x8686, 0xC1C1, u16::MAX]) + .collect(); + assert_eq!( + encode_rgba16(&opaque, false), + encode_rgba16(&opaque, true), + "rgba16" + ); +} From dbb0d8065179477999625f71a1adae61ebd110bd Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 19:18:39 -0400 Subject: [PATCH 22/54] docs: settle the counter rule, the png authority row and two counts Four corrections that this branch's new bench, size contract and golden re-capture made due. `benchmarking.md`'s counter table said "per-pixel or per-sample kernel -> ItemsCount", which reads as a rule `gamut-png`'s stage benches break: they count `BytesCount` over `crc32`, `pack_scanlines`, `filter_image` and `analyze8/16`. They do not break it. Those are byte-oriented stages of a codec pipeline whose natural item *is* a byte, and counting items would put their figures in a different unit from the crate's own encode benchmark and its size table, which are the figures a stage row exists to be read against. The workspace's actual `ItemsCount` users are all kernels whose item is not a byte -- `gamut-dsp` counts transform coefficients, `gamut-tonemap` `f32` samples, `gamut-color` `f64` samples and pixels, `gamut-bitstream` coded symbols, `gamut-cmm` transformed pixels -- and bytes per second would say nothing about any of them. So amend the rule rather than the bench: add the byte-oriented-stage row and sharpen the existing one to name the distinction it was always making. `testing.md`'s per-crate authority row for `gamut-png` named only "differential + conformance", omitting the size contract this branch adds, while `gamut-webp` names its own. Mirror it, and cite `crates/gamut-png/tests/size_contract.rs` from the technique table beside `gamut-webp/tests/effort.rs`. `mise.toml`'s `bench-test` comment says why `--benches` is passed and counts the workspace's benches to make the point; `gamut-png`'s is the sixteenth. (The "all 15 crates" at the top of the file is about `tooling/` and is a separate claim.) `gamut-png/tests/backends.rs`'s header says the goldens were captured before the seam existed, which the per-row note directly below it already contradicts for `rgb8_best_bruteforce`. State the exception in the header instead of leaving the two to disagree; no golden byte moves. --- crates/gamut-png/tests/backends.rs | 5 +++-- docs/benchmarking.md | 13 ++++++++++++- docs/testing.md | 4 ++-- mise.toml | 2 +- 4 files changed, 18 insertions(+), 6 deletions(-) diff --git a/crates/gamut-png/tests/backends.rs b/crates/gamut-png/tests/backends.rs index de966162..ed79077a 100644 --- a/crates/gamut-png/tests/backends.rs +++ b/crates/gamut-png/tests/backends.rs @@ -22,8 +22,9 @@ use gamut_png::{ // Byte-identical defaults // --------------------------------------------------------------------------------------------- -/// Bytes captured from the encoder **before** the seam existed. Pushing no backend must reproduce -/// them exactly: the registry is inert by construction, not merely "close enough". +/// Bytes captured from the encoder **before** the seam existed, except where a row's re-capture is +/// recorded below. Pushing no backend must reproduce them exactly: the registry is inert by +/// construction, not merely "close enough". /// /// This pins the *seam*, not the encoder — so a deliberate encoding improvement re-captures the /// affected row, and the change is recorded here rather than being absorbed silently: diff --git a/docs/benchmarking.md b/docs/benchmarking.md index 546457b9..d16542b5 100644 --- a/docs/benchmarking.md +++ b/docs/benchmarking.md @@ -41,9 +41,20 @@ Counter units are fixed by kind, so figures are comparable across suites: | codec encode/decode | `BytesCount` | **source pixel** bytes | | compressor | `BytesCount` | input bytes | | container / parser | `BytesCount` | payload bytes | -| per-pixel or per-sample kernel | `ItemsCount` | items | +| byte-oriented codec pipeline stage | `BytesCount` | bytes the stage consumes | +| per-pixel or per-sample kernel *whose item is not a byte* | `ItemsCount` | items | | one-off construction cost | none | — | +The last two rows split on what the kernel's natural unit actually is, because the counter is what +makes a figure comparable and a figure is only comparable to figures in the same unit. A stage +inside a codec pipeline — CRC, scanline packing, filtering, a colour-type scan — consumes the +byte stream the enclosing encoder consumes, so counting its bytes puts it in the same unit as the +crate's own encode benchmark and its size table, and a per-stage figure can be read against the +whole. `gamut-png`'s stage benches are all of this kind. `ItemsCount` is for a kernel whose item is +*not* a byte and would be lost by counting bytes: `gamut-dsp` counts transform coefficients, +`gamut-tonemap` `f32` samples, `gamut-color` `f64` samples and pixels, `gamut-bitstream` coded +symbols, `gamut-cmm` transformed pixels. Bytes per second would say nothing about any of those. + Fixtures are **generated, never vendored**, and each generator documents the one axis it exists for. Size them against the algorithm, not for speed: `gamut-png`'s corpus is 256×256 because RGB at that size is ~6× the DEFLATE window, and a 64×64 image fits *inside* it and would flatter every diff --git a/docs/testing.md b/docs/testing.md index 55039603..e0523990 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -72,7 +72,7 @@ the first one that can falsify the claim. | **conformance** | the specification ships vectors | | **pin / drift guard** | an artifact must still equal an authority (`gamut-iptc/tests/techreference.rs`, `gamut-jxl-sys/tests/version.rs`) | | **null-change invariance** | output must be *unchanged*, correctness belonging elsewhere (`gamut-webp/tests/default_bytes.rs`) | -| **size / effort contract** | an encoder knob's ladder is monotonic, deterministic, and correctness-independent (`gamut-webp/tests/effort.rs`) | +| **size / effort contract** | an encoder knob's ladder is monotonic, deterministic, and correctness-independent (`gamut-webp/tests/effort.rs`), or its output stays within a budget against the crate's oracle (`crates/gamut-png/tests/size_contract.rs`) | | **robustness** | input is hostile; the claim is "no panic, bounded allocation, typed error" | A hand-written sweep over five sizes is a property test written badly. A property asserting one @@ -189,7 +189,7 @@ mutation gates, and the stubs carry no function bodies. | gamut-icc | Little-CMS | differential | `IccProfile::parse` ☐ | | gamut-cmm | Little-CMS | differential | — | | gamut-deflate | zlib | differential | — | -| gamut-png | libpng (both directions) | differential + conformance | `PngDecoder` ☐ | +| gamut-png | libpng (both directions) | differential + conformance + size contract | `PngDecoder` ☐ | | gamut-jpeg | libjpeg-turbo | differential + exact-byte | `JpegDecoder` ☐ | | gamut-tiff | libtiff | differential | `TiffDecoder` ☐ | | gamut-dng | Adobe DNG SDK; libtiff (container) | conformance + differential | `DngDecoder` ☐ | diff --git a/mise.toml b/mise.toml index 50ccb405..a862b21a 100644 --- a/mise.toml +++ b/mise.toml @@ -238,7 +238,7 @@ run = "cargo bench --workspace" # an `unwrap` on an encode that now fails -- was invisible. # # `--benches` restricts this to bench targets. Without it `cargo bench` also builds every lib and -# test target in release just to look for `#[bench]` functions that do not exist here (all 15 +# test target in release just to look for `#[bench]` functions that do not exist here (all 16 # benches are `harness = false` Divan), which is a second full release build for nothing. [tasks.bench-test] description = "Run every bench once to prove it still executes (no timings; issue #437)" From 82220ccda3f149eb3f234e651c4b22c6212b316f Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 19:20:53 -0400 Subject: [PATCH 23/54] fix(png): budget the quantity the decoder budgets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The report walk capped the *filtered* stream at 64 MiB while documenting that cap as matching the decoder's image budget. The decoder budgets the *decoded* buffer instead, and the two differ by exactly one filter byte per scanline: a 4096x4096 RGBA8 image is 67 108 864 native bytes, which decodes on the default budget, and 67 112 960 filtered, which the walk declined — so `deconstruct` reported an undamaged file as damaged and `gamut inspect` exited non-zero on it. Two constants asserted to agree had drifted, so make the agreement structural. `ihdr::native_bytes` is now the single definition of the quantity; `PngDecoder::check_limits` reads it (byte-identical behaviour, pinned by `byte_budget_is_exact`), and `MAX_FILTERED_BYTES` / `within_inflation_budget` give way to `fits_decode_budget(header, max_image_bytes)`. The budget is a parameter, so the inclusive boundary is reachable from a unit test without a 64 MiB fixture. Inflation stays bounded: a file that passes inflates to at most the native bytes plus one per scanline. Kept, against the plan: `idat_ratio`'s `filtered_len == 0` guard. It was to be deleted as unreachable, but it is reachable in thirteen header bytes. §11.2.1 admits 2^31-1 square, which at RGBA16 implies 2^65 filtered bytes; `adam7::expected_stream_len` refuses to wrap and `deconstruct` reports such a file rather than erroring, leaving `filtered_len` zero. `gamut inspect` prints the ratio for every file it reads, so replacing the guard with a `debug_assert!` would have put a panic on a hostile-input path. The branch is pinned by a new accounting test instead, which is what makes it killable rather than equivalent. --- crates/gamut-png/src/decoder.rs | 27 +++++---- crates/gamut-png/src/deconstruct.rs | 82 +++++++++++++++++++++------- crates/gamut-png/src/ihdr.rs | 42 ++++++++++++++ crates/gamut-png/tests/accounting.rs | 36 ++++++++++++ 4 files changed, 155 insertions(+), 32 deletions(-) diff --git a/crates/gamut-png/src/decoder.rs b/crates/gamut-png/src/decoder.rs index cb95bbf0..85547f4e 100644 --- a/crates/gamut-png/src/decoder.rs +++ b/crates/gamut-png/src/decoder.rs @@ -35,7 +35,10 @@ use crate::palette::PngPalette; use crate::{adam7, inflate, pack}; /// Default cap on the decoded sample buffer: 64 MiB, a 4096×4096 RGBA8 image. -const DEFAULT_MAX_IMAGE_BYTES: usize = 64 << 20; +/// +/// `pub(crate)` because [`crate::deconstruct`] reports against the same budget: a file this +/// decoder decodes is one the report walk will inflate to count filters. +pub(crate) const DEFAULT_MAX_IMAGE_BYTES: usize = 64 << 20; /// Default cumulative cap on inflated metadata (iCCP/zTXt/iTXt) payloads: 16 MiB. const DEFAULT_MAX_METADATA_BYTES: usize = 16 << 20; /// The spec's own dimension bound (§11.2.1): width and height are 1 ..= 2³¹ − 1. @@ -360,17 +363,17 @@ impl PngDecoder { "PNG: image exceeds the dimension limit", )); } - let (width, height) = (header.width as usize, header.height as usize); - // Budget the *decoded* representation: one byte per sample below depth 16 (sub-byte - // depths are unpacked), two above. - let bytes_per_sample = if header.bit_depth == 16 { 2 } else { 1 }; - let native_bytes = width - .checked_mul(height) - .and_then(|pixels| pixels.checked_mul(header.color.channels())) - .and_then(|samples| samples.checked_mul(bytes_per_sample)) - .ok_or_else(|| { - Error::invalid_input(env!("CARGO_PKG_NAME"), "PNG: image dimensions overflow") - })?; + // Budget the *decoded* representation, via the one definition of that quantity, so the + // report walk in `crate::deconstruct` cannot come to budget a different one. + let native_bytes = ihdr::native_bytes( + header.width, + header.height, + header.color.channels(), + header.bit_depth, + ) + .ok_or_else(|| { + Error::invalid_input(env!("CARGO_PKG_NAME"), "PNG: image dimensions overflow") + })?; if native_bytes > self.max_image_bytes { return Err(Error::unsupported( env!("CARGO_PKG_NAME"), diff --git a/crates/gamut-png/src/deconstruct.rs b/crates/gamut-png/src/deconstruct.rs index 556f05d9..2ca41497 100644 --- a/crates/gamut-png/src/deconstruct.rs +++ b/crates/gamut-png/src/deconstruct.rs @@ -31,6 +31,7 @@ use gamut_core::{Error, Result}; use crate::chunk::{ChunkReader, RawChunk, SIGNATURE}; use crate::decoded::PngHeader; +use crate::decoder::DEFAULT_MAX_IMAGE_BYTES; use crate::filter::FilterType; use crate::{adam7, ihdr, inflate}; @@ -235,6 +236,12 @@ impl PngReport { /// Below 1.0 means the codestream compressed. Filtering and colour-type choice are *upstream* /// of this number, which is what makes it the right lens for attributing a size difference to /// the compressor rather than to the rest of the encoder. + /// + /// `0.0` when the filtered stream has no length. That is not a dead branch: IHDR admits + /// dimensions whose filtered stream overflows `usize` — 2³¹−1 square at RGBA16 is 2⁶⁵ bytes — + /// and [`deconstruct`] reports such a file rather than refusing it, leaving + /// [`filtered_len`](Self::filtered_len) zero. Thirteen header bytes reach it, so the guard is + /// what keeps `gamut inspect` from dividing by zero on a hostile file. #[must_use] pub fn idat_ratio(&self) -> f64 { if self.filtered_len == 0 { @@ -323,10 +330,6 @@ impl ChunkTally { } } -/// The largest filtered stream this walk will inflate to count filter choices. Matches the -/// decoder's own default image budget, so a report never allocates more than a decode would. -const MAX_FILTERED_BYTES: usize = 64 << 20; - /// Classifies every byte of `png` and, where the IDAT stream is sound and within budget, counts /// the scanline filter each row chose. /// @@ -428,7 +431,7 @@ pub fn deconstruct(png: &[u8]) -> Result { let passes = pass_stats(&native); let filtered_len = adam7::expected_stream_len(&native).unwrap_or(0); - let filters = filter_histogram(&idat, filtered_len, &passes); + let filters = filter_histogram(&native, &idat, filtered_len, &passes); Ok(PngReport { file_len: png.len(), @@ -475,14 +478,29 @@ fn pass_stats(header: &ihdr::Ihdr) -> Vec { out } -/// Whether a filtered stream of this length is worth inflating: non-empty, and within the budget. +/// Whether this file's IDAT stream is worth inflating to count filters: whether the image its +/// header describes fits `max_image_bytes`. +/// +/// The budgeted quantity is [`ihdr::native_bytes`] — the decoded buffer — because that is exactly +/// what [`crate::PngDecoder`] budgets, so "a report never allocates more than a decode would" +/// holds by construction. Budgeting the *filtered* stream instead states the same intent over a +/// different number: the two differ by one filter byte per scanline, so a 4096×4096 RGBA8 image +/// is 67 108 864 native bytes (decodes on the default budget) and 67 112 960 filtered — and the +/// report declined to scan a file the decoder decodes, reporting it as damaged. /// -/// Split out so the boundary is reachable from a unit test. Exercising it through [`deconstruct`] -/// would need a real 64 MiB stream to sit either side of the cap, and a hostile IHDR alone cannot -/// distinguish `>` from `>=` or `==` — every over-budget file is rejected a second time when the -/// inflated length fails to match, so the guard's exact comparison is invisible from outside. -fn within_inflation_budget(filtered_len: usize) -> bool { - filtered_len != 0 && filtered_len <= MAX_FILTERED_BYTES +/// Inflation is still bounded: the filtered stream is at most the native bytes plus one byte per +/// scanline, so a file that passes here inflates to under twice the budget. +/// +/// The budget is a parameter rather than a constant so the boundary is reachable from a unit test +/// without a 64 MiB fixture. +fn fits_decode_budget(header: &ihdr::Ihdr, max_image_bytes: usize) -> bool { + ihdr::native_bytes( + header.width, + header.height, + header.color.channels(), + header.bit_depth, + ) + .is_some_and(|native| native <= max_image_bytes) } /// Inflates the IDAT stream and counts the filter byte leading each scanline. @@ -491,11 +509,12 @@ fn within_inflation_budget(filtered_len: usize) -> bool { /// inflates to the wrong length, or carries a code §9.1 does not define. Every other figure in /// the report is derived from framing and IHDR, so it survives all of these. fn filter_histogram( + header: &ihdr::Ihdr, idat: &[u8], filtered_len: usize, passes: &[PassStats], ) -> Option { - if !within_inflation_budget(filtered_len) { + if !fits_decode_budget(header, DEFAULT_MAX_IMAGE_BYTES) { return None; } let stream = inflate::inflate_zlib(idat, filtered_len).ok()?; @@ -575,14 +594,37 @@ mod tests { assert!(report_with(&[], 0).is_fully_classified()); } + /// A header for the budget boundary, built directly: `ihdr::parse` would only add a byte + /// layout between the test and the quantity under test. + fn header(width: u32, height: u32, bit_depth: u8, color: ColorType) -> ihdr::Ihdr { + ihdr::Ihdr { + width, + height, + bit_depth, + color, + interlaced: false, + } + } + #[test] - fn the_inflation_budget_is_inclusive_and_rejects_an_empty_stream() { - // Exactly at the cap is worth inflating; one byte past is not. A zero-length stream has - // no scanlines to count and is rejected before any work. - assert!(!within_inflation_budget(0)); - assert!(within_inflation_budget(1)); - assert!(within_inflation_budget(MAX_FILTERED_BYTES)); - assert!(!within_inflation_budget(MAX_FILTERED_BYTES + 1)); + fn the_decode_budget_is_inclusive_and_measures_the_decoded_image() { + // 4096x4096 RGBA8 is exactly the decoder's default budget, so the walk must scan it. Its + // *filtered* stream is 67 112 960 bytes — 4096 more, one filter byte per scanline — which + // is how a cap stated over the filtered length came to decline an image that decodes. + let at_budget = header(4096, 4096, 8, ColorType::TruecolorAlpha); + assert!(fits_decode_budget(&at_budget, DEFAULT_MAX_IMAGE_BYTES)); + assert!(!fits_decode_budget(&at_budget, DEFAULT_MAX_IMAGE_BYTES - 1)); + assert!(fits_decode_budget(&at_budget, DEFAULT_MAX_IMAGE_BYTES + 1)); + // One pixel past the budget, at the same dimensions: the depth is the difference. + assert!(!fits_decode_budget( + &header(4096, 4096, 16, ColorType::TruecolorAlpha), + DEFAULT_MAX_IMAGE_BYTES + )); + // A header whose decoded size overflows `usize` is declined, not wrapped. + assert!(!fits_decode_budget( + &header(0x7FFF_FFFF, 0x7FFF_FFFF, 16, ColorType::TruecolorAlpha), + usize::MAX + )); } #[test] diff --git a/crates/gamut-png/src/ihdr.rs b/crates/gamut-png/src/ihdr.rs index f70e759d..3347ffb6 100644 --- a/crates/gamut-png/src/ihdr.rs +++ b/crates/gamut-png/src/ihdr.rs @@ -41,6 +41,33 @@ impl Ihdr { } } +/// The decoded image's byte cost: `width × height × channels × (2 if the depth is 16 else 1)`. +/// +/// **The single definition** of the quantity PNG budgets. [`crate::PngDecoder`] bounds it before +/// allocating anything, and [`crate::deconstruct`] gates its optional IDAT inflation on the same +/// number, so "a report never allocates more than a decode would" holds structurally instead of +/// being asserted by two constants over two different quantities. +/// +/// It counts the **unpacked** buffer, which is what a decode produces: one byte per sample at +/// depths 1/2/4/8 (sub-byte samples are unpacked, §7.2), two at depth 16. The *filtered* stream is +/// a different, larger quantity — it adds one filter byte per scanline (§9.1) — so the two must +/// not be interchanged. +/// +/// `None` when the product overflows `usize`; the caller decides whether that is an error or a +/// refusal. +pub(crate) fn native_bytes( + width: u32, + height: u32, + channels: usize, + bit_depth: u8, +) -> Option { + let bytes_per_sample = if bit_depth == 16 { 2 } else { 1 }; + (width as usize) + .checked_mul(height as usize)? + .checked_mul(channels)? + .checked_mul(bytes_per_sample) +} + /// Parses and validates a 13-byte IHDR payload (PNG spec §11.2.1). /// /// # Errors @@ -149,6 +176,21 @@ mod tests { assert_eq!(parsed.bits_per_pixel(), 32); } + #[test] + fn native_bytes_counts_unpacked_samples() { + // 4096x4096 RGBA8 is exactly the decoder's 64 MiB default budget — the image the two + // budgets used to disagree about. + assert_eq!(native_bytes(4096, 4096, 4, 8), Some(64 << 20)); + // Depth 16 is the only depth that costs two bytes per sample... + assert_eq!(native_bytes(4096, 4096, 4, 16), Some(128 << 20)); + // ...and every sub-byte depth costs one, because a decode unpacks it (§7.2). A packed + // count would be eight times smaller here, and the row padding would round it up again. + assert_eq!(native_bytes(9, 4, 1, 1), Some(36)); + assert_eq!(native_bytes(9, 4, 1, 8), Some(36)); + // Overflow is refused rather than wrapped: 4 channels past the largest square. + assert_eq!(native_bytes(u32::MAX, u32::MAX, 4, 8), None); + } + #[test] fn parse_accepts_adam7() { let parsed = parse(&payload(3, 2, 8, 2, 1)).unwrap(); diff --git a/crates/gamut-png/tests/accounting.rs b/crates/gamut-png/tests/accounting.rs index 422593d0..b463b609 100644 --- a/crates/gamut-png/tests/accounting.rs +++ b/crates/gamut-png/tests/accounting.rs @@ -499,6 +499,42 @@ fn an_over_budget_image_reports_everything_but_the_histogram() { assert_eq!(report.header.width, 1 << 30); } +/// A header whose filtered stream overflows `usize` still reports, and its ratio is finite. +/// +/// §11.2.1 allows dimensions up to 2³¹−1 each, so 2³¹−1 square at RGBA16 implies 2⁶⁵ filtered +/// bytes: `filtered_len` saturates to 0 rather than wrapping, and `idat_ratio` would otherwise +/// divide by it. Thirteen header bytes reach this, and `gamut inspect` prints the ratio for every +/// file it reads, so the guard in `idat_ratio` is live code on a hostile-input path — not the dead +/// branch a filtered-stream budget would have made it. +#[test] +fn a_header_whose_stream_overflows_reports_a_zero_ratio_rather_than_dividing_by_it() { + let png = common::png_from_chunks(&[ + common::chunk( + b"IHDR", + &common::ihdr_payload(0x7FFF_FFFF, 0x7FFF_FFFF, 16, 6, 0), + ), + common::chunk(b"IDAT", &common::zlib(&[0u8; 8])), + common::chunk(b"IEND", &[]), + ]); + let report = deconstruct(&png).expect("an unrepresentable stream is reported, not an error"); + + assert_covers(&report.segments, png.len()); + assert_eq!( + report.filtered_len, 0, + "the implied stream is not representable" + ); + assert!(report.idat_compressed > 0, "there is a numerator to divide"); + assert_eq!( + report.idat_ratio(), + 0.0, + "no division by zero, and not an infinity" + ); + assert!( + report.passes.is_empty(), + "no pass geometry is representable either" + ); +} + #[test] fn a_file_with_no_header_to_report_on_is_an_error() { assert!(deconstruct(&[]).is_err(), "empty input"); From c16ac221f3be81c8880825ab0a1d01d3d098cb0a Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 19:21:38 -0400 Subject: [PATCH 24/54] test(png): cover the greyscale colour key `Reduced::GrayKeyed` is reachable and correct, but nothing in the suite produced one, so neither `analyze8`'s `all_gray` split inside the keyed arm nor the encoder's arm for it had a test that could see them. Two tests, at the two scopes the placement rule forces. `Reduced` is private, so the analysis side is pinned inline: grey with binary alpha, 64 opaque levels, and a 65-entry palette that keeps the palette estimate (540 bytes) out of a race the key wins at 270. The encoder side needs libpng, and is pinned in `colour_key.rs` as the greyscale twin of the existing truecolour differential: colour type grey at depth 8, a two-byte `tRNS`, and an exact round trip. The key is grey 7 rather than 0 in both, so the `tRNS` sample's byte order is observable -- written little-endian it would read `[7, 0]`, which a key of 0 could not distinguish from the correct `[0, 7]`. The greyscale win is thinner than truecolour's, since dropping the alpha plane saves one byte per pixel rather than three against the same flat 14-byte chunk. Measured, it wins anyway at every square from 32 to 256: 499 bytes against 626 at 128, about 20%, so the fixture needs no size threshold. --- crates/gamut-png/src/reduce.rs | 50 +++++++++++++++++++ crates/gamut-png/tests/colour_key.rs | 74 +++++++++++++++++++++++++++- 2 files changed, 123 insertions(+), 1 deletion(-) diff --git a/crates/gamut-png/src/reduce.rs b/crates/gamut-png/src/reduce.rs index ad3504f9..0b1033e4 100644 --- a/crates/gamut-png/src/reduce.rs +++ b/crates/gamut-png/src/reduce.rs @@ -773,6 +773,56 @@ mod tests { } } + /// `Reduced::GrayKeyed` -- the greyscale twin of `Rgb8Keyed`, reachable and correct but + /// produced by nothing else in the suite, so `analyze8`'s `all_gray` split inside the keyed + /// arm had no test that could see it. + /// + /// Grey + binary alpha, every invisible pixel sharing grey 7, and 64 distinct opaque grey + /// levels 8..=71 that no invisible pixel can collide with. The estimates that race + /// (`pixel_count` = 256): + /// + /// - keyed: `256 + GREY_KEY_COST` = **270** + /// - grey + alpha: `256 * 2` = 512, which is also the input size, so it cannot win + /// - palette: 65 entries needs depth 8, `256 + 65 * 4 + 24` = 540 + /// + /// 65 entries is what keeps the palette out of the race: below 17 the index depth drops to 4 + /// and the palette would win on a fixture this large. + #[test] + fn binary_alpha_grey_reduces_to_a_colour_keyed_greyscale() { + let ga: Vec = (0..256u32) + .flat_map(|i| { + if i.is_multiple_of(5) { + [7, 0] // invisible, all one grey + } else { + [8 + (i % 64) as u8, 255] + } + }) + .collect(); + + match analyze8(&ga, 2) { + Some(Reduced::GrayKeyed { samples, key }) => { + assert_eq!(key, 7, "the one grey every invisible pixel carries"); + assert_eq!(samples.len(), 256, "one sample per pixel, alpha gone"); + // The key erases whatever wears it, so nothing visible may wear it. + for (i, px) in ga.as_chunks::<2>().0.iter().enumerate() { + if px[1] == 255 { + assert_ne!(samples[i], key, "visible pixel {i} would be erased"); + } + } + } + other => panic!( + "expected GrayKeyed, got {}", + match other { + Some(Reduced::Indexed { .. }) => "Indexed", + Some(Reduced::GrayAlpha8(_)) => "GrayAlpha8", + Some(Reduced::Rgb8Keyed { .. }) => "Rgb8Keyed", + Some(_) => "some other reduction", + None => "no reduction", + } + ), + } + } + #[test] fn grey_alpha_noise_keeps_its_encoding() { let ga: Vec = (0..600u32) diff --git a/crates/gamut-png/tests/colour_key.rs b/crates/gamut-png/tests/colour_key.rs index 26f5541e..ce2d6a31 100644 --- a/crates/gamut-png/tests/colour_key.rs +++ b/crates/gamut-png/tests/colour_key.rs @@ -8,7 +8,7 @@ mod common; -use gamut_core::{Dimensions, EncodeImage, ImageRef, Rgba8}; +use gamut_core::{Dimensions, EncodeImage, GrayAlpha8, ImageRef, Rgba8}; use gamut_png::{FilterStrategy, Level, PngEncoder, deconstruct}; /// 128, not something smaller, and the reason is the whole design of the reduction. @@ -218,3 +218,75 @@ fn read_chunk(png: &[u8], want: &[u8; 4]) -> Option> { } None } + +/// Binary alpha over a grey ramp: the greyscale twin of [`keyable_rgba`]. Grey 7 stands for +/// "invisible" and the visible ramp starts at 8, so no opaque pixel can collide with the key, and +/// 200 distinct visible levels keep a palette out of the race. +fn keyable_grey_alpha() -> Vec { + let mut buf = Vec::with_capacity((SIDE * SIDE * 2) as usize); + for y in 0..SIDE { + for x in 0..SIDE { + if outside(x, y) { + buf.extend_from_slice(&[7, 0]); + } else { + buf.extend_from_slice(&[8 + ((x + y) % 200) as u8, 255]); + } + } + } + buf +} + +/// The greyscale twin of [`a_colour_key_drops_the_alpha_channel_losslessly`], covering +/// `Reduced::GrayKeyed` -- reachable and correct, but produced by nothing else in the suite, so +/// the encoder's arm for it (the `ColorType::Grayscale` choice, and the single 16-bit big-endian +/// `tRNS` sample) had no test that could see it. +/// +/// The win is thinner here than for truecolour: dropping the alpha plane saves one byte per pixel +/// rather than three, while the `tRNS` chunk still costs a flat 14. It is a win regardless -- +/// measured at `SIDE`, brute-force filtered at `Level::Best`, 499 bytes keyed against 626 as +/// `GrayAlpha8`, about 20% -- and it stayed a win at every square from 32 to 256, so no size +/// threshold is needed on this side. +/// +/// The key is grey 7 rather than 0 deliberately: a `tRNS` written little-endian would read +/// `[7, 0]`, which a key of 0 could not tell from the correct `[0, 7]`. +#[test] +fn a_greyscale_colour_key_drops_the_alpha_channel_losslessly() { + let src = keyable_grey_alpha(); + let dims = Dimensions::new(SIDE, SIDE).expect("valid dimensions"); + let mut png = Vec::new(); + PngEncoder::new() + .with_compression(Level::Best) + .with_filter(FilterStrategy::BruteForce) + .with_auto_reduce(true) + .encode_image( + ImageRef::::new(&src, dims).expect("buffer matches dimensions"), + &mut png, + ) + .expect("encode"); + + let dec = libpng_oracle::decode(&png); + assert_eq!( + dec.color_type, + libpng_oracle::COLOR_GRAY, + "the alpha plane is gone" + ); + assert_eq!(dec.bit_depth, 8, "a keyed grey is always depth 8"); + assert_eq!( + read_chunk(&png, b"tRNS").expect("tRNS present"), + vec![0, 7], + "one 16-bit big-endian sample naming grey 7" + ); + + // The whole claim: libpng renders the key, and every pixel comes back exactly. + let (_, _, rgba) = libpng_oracle::decode_rgba8(&png); + let expected: Vec = src + .as_chunks::<2>() + .0 + .iter() + .flat_map(|px| { + let grey = if px[1] == 0 { 7 } else { px[0] }; + [grey, grey, grey, px[1]] + }) + .collect(); + assert_eq!(rgba, expected, "the grey colour key resolves losslessly"); +} From ef736169ce805bb023be7f77bf3a86e12660f2b5 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 19:22:08 -0400 Subject: [PATCH 25/54] test(png): pin the size race a colour key can lose `write_reduced_or_native` races a chunk-carrying reduction against the unreduced encoding, and its `carries_chunks` set decides which reductions enter that race. The palette member had both sides covered; the keyed members had only the winning one. The three existing negative tests here all stay RGBA because no key was ever *offered* -- partial alpha, two invisible colours, a collision with a visible pixel -- not because a valid key lost on size, so dropping `Rgb8Keyed` from the set would have gone unnoticed. Add the losing side at 32x32 on the existing fixture, reconstructing the candidate that lost: the encoder's `Rgb8Keyed` arm is the RGB stream through the same configuration plus one 18-byte `tRNS`, so the test can assert the declined encoding really was the larger one (279 bytes against RGBA's 274) rather than merely that RGBA survived. Parameterise the fixture by side to do it, and correct the module doc while it is in hand: the crossover was measured at 32, not below 128 as the `SIDE` comment claimed -- at 48 the key already wins, 347 against 353. --- crates/gamut-png/tests/colour_key.rs | 92 ++++++++++++++++++++++++---- 1 file changed, 79 insertions(+), 13 deletions(-) diff --git a/crates/gamut-png/tests/colour_key.rs b/crates/gamut-png/tests/colour_key.rs index ce2d6a31..080a76ca 100644 --- a/crates/gamut-png/tests/colour_key.rs +++ b/crates/gamut-png/tests/colour_key.rs @@ -8,24 +8,33 @@ mod common; -use gamut_core::{Dimensions, EncodeImage, GrayAlpha8, ImageRef, Rgba8}; +use gamut_core::{Dimensions, EncodeImage, GrayAlpha8, ImageRef, Rgb8, Rgba8}; use gamut_png::{FilterStrategy, Level, PngEncoder, deconstruct}; /// 128, not something smaller, and the reason is the whole design of the reduction. /// /// A colour key costs a flat 18-byte `tRNS` chunk that DEFLATE cannot touch, and buys an alpha /// plane that usually compresses very well. So whether it wins is size-dependent, exactly as the -/// palette is: measured on this fixture the analysis offers `Rgb8Keyed` at every size, and -/// `write_reduced_or_native` keeps plain RGBA below this size before taking the key at 128, -/// where it is worth about 7% (863 bytes against 926). +/// palette is: measured on this fixture the analysis offers `Rgb8Keyed` at every size, but +/// `write_reduced_or_native` only takes it once the chunk is amortised. Brute-force filtered at +/// `Level::Best`, keyed against plain RGBA: 32 declines it (279 against 274), 48 takes it (347 +/// against 353), and by 128 it is worth about 7% (863 against 926). /// /// That also matters for the *negative* tests below. Asserting "stayed RGBA" at a size where the /// key would never have been taken anyway proves nothing; at 128 a valid key is taken, so RGBA -/// there is real evidence the reduction declined. +/// there is real evidence the reduction declined. The one test that needs the *losing* side of +/// that race says so and picks its own size. const SIDE: u32 = 128; +/// The 18 bytes a truecolour `tRNS` adds to an encoding: 4 length + 4 type + 6 payload + 4 CRC. +const TRNS_RGB_CHUNK: usize = 18; + fn encode(samples: &[u8]) -> Vec { - let dims = Dimensions::new(SIDE, SIDE).expect("valid dimensions"); + encode_at(SIDE, samples) +} + +fn encode_at(side: u32, samples: &[u8]) -> Vec { + let dims = Dimensions::new(side, side).expect("valid dimensions"); let image = ImageRef::::new(samples, dims).expect("buffer matches dimensions"); let mut out = Vec::new(); PngEncoder::new() @@ -46,18 +55,26 @@ fn encode(samples: &[u8]) -> Vec { /// race correctly declined it. A solid transparent region keeps the colour channels smooth, which /// is the shape real sprites and icons have and the shape where dropping the alpha plane pays. fn outside(x: u32, y: u32) -> bool { - let cx = i64::from(x) - i64::from(SIDE) / 2; - let cy = i64::from(y) - i64::from(SIDE) / 2; - cx * cx + cy * cy >= (i64::from(SIDE) * i64::from(SIDE)) / 9 + outside_at(x, y, SIDE) +} + +fn outside_at(x: u32, y: u32, side: u32) -> bool { + let cx = i64::from(x) - i64::from(side) / 2; + let cy = i64::from(y) - i64::from(side) / 2; + cx * cx + cy * cy >= (i64::from(side) * i64::from(side)) / 9 } /// Binary alpha, one shared invisible colour, and enough distinct visible colours that a palette /// is not on the table — so the colour key is the only reduction available. fn keyable_rgba() -> Vec { - let mut buf = Vec::with_capacity((SIDE * SIDE * 4) as usize); - for y in 0..SIDE { - for x in 0..SIDE { - if outside(x, y) { + keyable_rgba_at(SIDE) +} + +fn keyable_rgba_at(side: u32) -> Vec { + let mut buf = Vec::with_capacity((side * side * 4) as usize); + for y in 0..side { + for x in 0..side { + if outside_at(x, y, side) { // Invisible, all sharing one colour no visible pixel below can produce. buf.extend_from_slice(&[1, 2, 3, 0]); } else { @@ -290,3 +307,52 @@ fn a_greyscale_colour_key_drops_the_alpha_channel_losslessly() { .collect(); assert_eq!(rgba, expected, "the grey colour key resolves losslessly"); } + +/// The *losing* side of the race in `write_reduced_or_native`, which its `carries_chunks` set +/// exists for. +/// +/// The other negative tests here stay RGBA because no key was ever *offered* -- partial alpha, two +/// invisible colours, a collision with a visible pixel. This one offers a perfectly valid key and +/// has it declined on size alone, which is the only way the `Rgb8Keyed` member of `carries_chunks` +/// is observable: drop it and the encoder would emit the larger keyed file without racing it. +/// +/// Measured on `keyable_rgba_at(32)`, brute-force filtered at `Level::Best`: plain RGBA is 274 +/// bytes and `RGB + tRNS` is 279 (261 for the RGB stream plus the flat 18-byte chunk). 32 is the +/// largest square where the key loses -- by 48 it already wins, 347 against 353. +#[test] +fn a_colour_key_that_would_cost_bytes_is_declined() { + const SMALL: u32 = 32; + let src = keyable_rgba_at(SMALL); + let chosen = encode_at(SMALL, &src); + assert_eq!( + libpng_oracle::decode(&chosen).color_type, + libpng_oracle::COLOR_RGBA, + "the key is valid at this size, so only its cost can have declined it" + ); + + // What the key would have cost. The encoder's `Rgb8Keyed` arm is the RGB stream through this + // same configuration plus one `tRNS`, so the losing candidate is reproducible from outside. + let rgb: Vec = src + .as_chunks::<4>() + .0 + .iter() + .flat_map(|px| [px[0], px[1], px[2]]) + .collect(); + let dims = Dimensions::new(SMALL, SMALL).expect("valid dimensions"); + let mut keyed = Vec::new(); + PngEncoder::new() + .with_compression(Level::Best) + .with_filter(FilterStrategy::BruteForce) + .with_auto_reduce(false) + .encode_image( + ImageRef::::new(&rgb, dims).expect("buffer matches dimensions"), + &mut keyed, + ) + .expect("encode"); + let keyed_len = keyed.len() + TRNS_RGB_CHUNK; + assert!( + keyed_len > chosen.len(), + "the declined candidate must really be the larger one: keyed {keyed_len} vs RGBA {}", + chosen.len() + ); +} From 589261ff3d1b9c06ac83d750e835d4864ac85f20 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 19:23:59 -0400 Subject: [PATCH 26/54] test(png): re-pin sub-byte indexed auto-reduce Two halves of one gap. The off-grid grey case had been weakened from an exact colour-type assertion to `COLOR_GRAY || COLOR_PALETTE`; that fixture produces grey at depth 8, so the palette arm was a branch no input could take. Assert the colour type exactly again and say in the comment where the palette case is covered instead. It is covered here. `a_palette_is_chosen_when_it_actually_wins` needs 64 colours before the race takes the palette at all, and 64 entries is depth 8, so the encoder's `depth < 8` path into `pack::pack_scanlines` and `index_bit_depth`'s `3..=4 => 2` arm were only ever reached by inputs whose palette was then declined. Four colours at 192x192, arranged by a finalizer-quality hash of the pixel index rather than in blocks: blocked, the RGBA stream compresses away and the race keeps it, which is why the 64-colour fixture needed 64 colours. Scattered, both streams sit near their entropy and the 2-bit packing is the whole difference -- 9500 bytes indexed (9216 of payload) against 19 135 as RGBA. A cheaper mix was tried first and rejected: one multiply and a shift is periodic in x, DEFLATE finds the period, and the same fixture came out at 272 bytes. --- crates/gamut-png/tests/oracle.rs | 96 +++++++++++++++++++++++++++++--- 1 file changed, 89 insertions(+), 7 deletions(-) diff --git a/crates/gamut-png/tests/oracle.rs b/crates/gamut-png/tests/oracle.rs index 772bb41b..f2f8b478 100644 --- a/crates/gamut-png/tests/oracle.rs +++ b/crates/gamut-png/tests/oracle.rs @@ -571,19 +571,20 @@ fn extended_auto_reduce_covers_grey_and_sixteen_bit_inputs() { // Low-cardinality grey off the scale grid. `reduce::analyze8` offers a 2-bit grey palette, // but on a fixture this small and this regular the plain 8-bit grey stream compresses to less - // than the palette's PLTE and framing, so `write_reduced_or_native` keeps grey. What matters - // here is that the pixels survive whichever wins. + // than the palette's PLTE and framing, so `write_reduced_or_native` keeps grey. Asserted + // exactly: no input reaches this line and comes back paletted, so admitting that as an + // alternative would be a branch nothing can take. The size at which a palette does win, and + // is packed below 8 bits, is covered by its own test at the end of this file. let off_grid: Vec = (0..n).map(|i| [5u8, 9, 200][i % 3]).collect(); let mut png = Vec::new(); encoder() .encode_image(ImageRef::::new(&off_grid, dims).unwrap(), &mut png) .expect("encode"); let dec = libpng_oracle::decode(&png); - assert!( - dec.color_type == libpng_oracle::COLOR_GRAY - || dec.color_type == libpng_oracle::COLOR_PALETTE, - "off-grid grey stays grey or becomes a grey palette, got {}", - dec.color_type + assert_eq!( + dec.color_type, + libpng_oracle::COLOR_GRAY, + "off-grid grey stays grey at this size" ); let (_, _, rgba) = libpng_oracle::decode_rgba8(&png); let expected: Vec = off_grid.iter().flat_map(|&v| [v, v, v, 255]).collect(); @@ -728,3 +729,84 @@ fn every_filter_strategy_survives_the_libpng_round_trip() { assert_eq!(dec.pixels, src, "{strategy:?} did not round-trip"); } } + +/// Sub-byte indexed auto-reduce: the palette wins *and* its index depth drops below 8. +/// +/// `a_palette_is_chosen_when_it_actually_wins` needs 64 colours to make the palette win, which is +/// depth 8 -- so the encoder's `depth < 8` path into `pack::pack_scanlines`, and +/// `reduce::index_bit_depth`'s `3..=4 => 2` arm, were only reached by inputs whose palette the +/// race then declined. +/// +/// Four colours, and **pseudo-random** rather than blocked. Blocked, the RGBA stream compresses +/// away and `write_reduced_or_native` correctly keeps it -- which is exactly why the 64-colour +/// fixture needed 64 colours. Scattered, the four-symbol stream is near its entropy either way, +/// so the 2-bit packing is the whole difference. Measured at 192x192, `Level::Best`: 9500 bytes +/// indexed (36 864 pixels at two bits is 9216 of payload) against 19 135 as RGBA, about 50%. +#[test] +fn a_small_palette_is_packed_to_a_sub_byte_index_depth() { + let (w, h) = (192u32, 192u32); + let dims = Dimensions::new(w, h).unwrap(); + const PALETTE: [[u8; 4]; 4] = [ + [220, 30, 40, 255], + [30, 200, 60, 255], + [40, 60, 210, 255], + [200, 190, 20, 255], + ]; + let mut src = Vec::with_capacity((w * h * 4) as usize); + for y in 0..h { + for x in 0..w { + // A finalizer-quality avalanche over the pixel index. A cheaper mix (one multiply + // and a shift) is periodic in x, and DEFLATE finds the period: the same fixture came + // out at 272 bytes, which would have proved nothing about packing. + let mut hash = y * w + x; + hash ^= hash >> 16; + hash = hash.wrapping_mul(0x7feb_352d); + hash ^= hash >> 15; + hash = hash.wrapping_mul(0x846c_a68b); + hash ^= hash >> 16; + src.extend_from_slice(&PALETTE[(hash & 3) as usize]); + } + } + + let reduced = encode_auto_reduced(&src, dims); + let dec = libpng_oracle::decode(&reduced); + assert_eq!( + dec.color_type, + libpng_oracle::COLOR_PALETTE, + "four colours over 36 864 pixels is a palette" + ); + assert_eq!(dec.bit_depth, 2, "and four entries need only two bits"); + assert_eq!( + read_chunk(&reduced, b"PLTE").expect("PLTE present").len(), + 12, + "four RGB triples" + ); + + let mut plain = Vec::new(); + PngEncoder::new() + .with_compression(Level::Best) + .encode_image(ImageRef::::new(&src, dims).unwrap(), &mut plain) + .expect("encode"); + assert!( + reduced.len() < plain.len(), + "packed indices beat RGBA: {} vs {}", + reduced.len(), + plain.len() + ); + + let (_, _, rgba) = libpng_oracle::decode_rgba8(&reduced); + assert_eq!(rgba, src, "the packed palette resolves losslessly"); +} + +/// The payload of the first chunk of this type, if present. +fn read_chunk(png: &[u8], want: &[u8; 4]) -> Option> { + let mut at = 8usize; + while at + 12 <= png.len() { + let len = u32::from_be_bytes([png[at], png[at + 1], png[at + 2], png[at + 3]]) as usize; + if &png[at + 4..at + 8] == want { + return Some(png[at + 8..at + 8 + len].to_vec()); + } + at += 12 + len; + } + None +} From 8dcac02f6d120b8d09c417cc728df5f494262fba Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 19:26:32 -0400 Subject: [PATCH 27/54] fix(png-cli): say why the filter scan was skipped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PngReport::filters` was `Option`, so "no histogram" conflated a file this reader declined to inflate with one whose compressed data is broken — and `is_intact` treated both as damage. Now that the walk budgets what the decoder budgets, that conflation is the last thing standing between a large sound PNG and an intact verdict. `FilterScan` is `Counted(FilterHistogram)` or `Skipped(SkippedFilterScan)`, the reason being `#[repr(u8)]` plain data with explicit, permanent, append-only discriminants: `OverBudget`, `CorruptStream`, `LengthMismatch`, `UndefinedFilterCode`. `SkippedFilterScan::is_damage` is the single source of truth for the grading question — only `OverBudget` is not damage, since it describes the reader's budget rather than the file — and `is_intact` narrows its conjunct to `!filters.is_damage()` rather than dropping it, because a corrupt zlib payload under a valid CRC is damage nothing else in the report can see. `PngReport::native_bytes` exposes the budgeted quantity, so a caller can tell what an `OverBudget` verdict was measured against. `gamut inspect` prints the reason through a `filter_skip_label` with a wildcard arm, and pushes a damage-bearing skip into the findings list before printing it — the exit message used to read "0 finding(s)" while exiting non-zero on a file whose only defect was its IDAT stream. --- crates/gamut-cli/src/commands/inspect.rs | 38 ++++- crates/gamut-png/benches/encode.rs | 2 +- crates/gamut-png/src/deconstruct.rs | 199 ++++++++++++++++++++--- crates/gamut-png/src/lib.rs | 3 +- crates/gamut-png/tests/accounting.rs | 78 +++++++-- 5 files changed, 281 insertions(+), 39 deletions(-) diff --git a/crates/gamut-cli/src/commands/inspect.rs b/crates/gamut-cli/src/commands/inspect.rs index 8eb2a8bd..f8be1aa3 100644 --- a/crates/gamut-cli/src/commands/inspect.rs +++ b/crates/gamut-cli/src/commands/inspect.rs @@ -364,7 +364,7 @@ fn print_lines(label: &str, lines: &[String]) { /// Deconstructs a PNG and prints where its bytes went, exiting non-zero when the file is not a /// complete, undamaged datastream. fn inspect_png(path: &std::path::Path, data: &[u8]) -> Result<(), CliError> { - use gamut::png::{FilterType, SegmentKind}; + use gamut::png::{FilterScan, FilterType, SegmentKind}; let report = gamut::png::deconstruct(data)?; let header = report.header; @@ -416,7 +416,7 @@ fn inspect_png(path: &std::path::Path, data: &[u8]) -> Result<(), CliError> { } match report.filters { - Some(h) => { + FilterScan::Counted(h) => { let n = |f| h.count(f); println!( " filters: None {} / Sub {} / Up {} / Average {} / Paeth {} ({} scanlines)", @@ -428,7 +428,12 @@ fn inspect_png(path: &std::path::Path, data: &[u8]) -> Result<(), CliError> { h.total() ); } - None => println!(" filters: unavailable (IDAT not inflatable within budget)"), + FilterScan::Skipped(reason) => { + println!( + " filters: not counted — {}", + filter_skip_label(reason) + ); + } } if report.passes.len() > 1 { @@ -441,7 +446,7 @@ fn inspect_png(path: &std::path::Path, data: &[u8]) -> Result<(), CliError> { } } - let damaged: Vec = report + let mut damaged: Vec = report .segments .iter() .filter_map(|seg| match seg.kind { @@ -467,6 +472,17 @@ fn inspect_png(path: &std::path::Path, data: &[u8]) -> Result<(), CliError> { _ => None, }) .collect(); + // A skip the file itself caused is a finding, and it is counted before the list is printed so + // the exit message cannot report "0 finding(s)" while exiting non-zero. An over-budget skip is + // not damage — nothing is known to be wrong with the file — so it is not one. + if let FilterScan::Skipped(reason) = report.filters + && reason.is_damage() + { + damaged.push(format!( + "filters not counted — {}", + filter_skip_label(reason) + )); + } print_lines("findings", &damaged); println!(" classified: {}", yes_no(report.is_fully_classified())); @@ -483,6 +499,20 @@ fn inspect_png(path: &std::path::Path, data: &[u8]) -> Result<(), CliError> { } } +/// Renders why a PNG's scanline filters were not counted. +fn filter_skip_label(reason: gamut::png::SkippedFilterScan) -> &'static str { + use gamut::png::SkippedFilterScan as Reason; + match reason { + Reason::OverBudget => "the image is larger than the reader's byte budget", + Reason::CorruptStream => "the IDAT stream is corrupt or truncated", + Reason::LengthMismatch => "the IDAT stream inflated to the wrong length", + Reason::UndefinedFilterCode => "a scanline carries an undefined filter code", + // `SkippedFilterScan` is non-exhaustive; describe future reasons generically. They are + // damage by default, so the finding is still raised. + _ => "the scan could not be trusted", + } +} + fn format_name(format: Format) -> &'static str { match format { Format::Tiff => "TIFF", diff --git a/crates/gamut-png/benches/encode.rs b/crates/gamut-png/benches/encode.rs index b1e72417..1971964d 100644 --- a/crates/gamut-png/benches/encode.rs +++ b/crates/gamut-png/benches/encode.rs @@ -211,7 +211,7 @@ fn print_stage_table() { for case in corpus() { let png = case.gamut(BEST.0, BEST.1, BEST.2); let report = deconstruct(&png).expect("gamut's own output deconstructs"); - let filters = report.filters.map_or_else( + let filters = report.filters.histogram().map_or_else( || "-".to_string(), |h| { let n = |f| h.count(f); diff --git a/crates/gamut-png/src/deconstruct.rs b/crates/gamut-png/src/deconstruct.rs index 2ca41497..fb249d97 100644 --- a/crates/gamut-png/src/deconstruct.rs +++ b/crates/gamut-png/src/deconstruct.rs @@ -148,6 +148,88 @@ impl FilterHistogram { } } +/// The outcome of the walk's optional filter scan: the counts, or why there are none. +/// +/// The scan is the one part of a report that has to inflate the IDAT stream, so it is the one +/// part that can be absent. Which is why the absence is *typed*: "no histogram" conflates a file +/// this reader declined to inflate with a file whose compressed data is broken, and only the +/// second is damage. [`is_damage`](Self::is_damage) answers that question once, for both +/// [`PngReport::is_intact`] and any caller that has to grade a file. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FilterScan { + /// The IDAT stream inflated to the expected length and every scanline's filter code was read. + Counted(FilterHistogram), + /// No counts, for the stated reason. + Skipped(SkippedFilterScan), +} + +impl FilterScan { + /// The per-filter counts, if the scan ran. + #[must_use] + pub fn histogram(self) -> Option { + match self { + Self::Counted(histogram) => Some(histogram), + Self::Skipped(_) => None, + } + } + + /// Why there are no counts, if there are none. + #[must_use] + pub fn skipped(self) -> Option { + match self { + Self::Counted(_) => None, + Self::Skipped(reason) => Some(reason), + } + } + + /// Whether the missing counts mean the *file* is damaged — see + /// [`SkippedFilterScan::is_damage`]. A scan that ran is never damage. + #[must_use] + pub fn is_damage(self) -> bool { + match self { + Self::Counted(_) => false, + Self::Skipped(reason) => reason.is_damage(), + } + } +} + +/// Why a [`FilterScan`] carries no counts. +/// +/// `#[repr(u8)]` with explicit discriminants, which are **permanent and append-only**: the value +/// is plain data a C caller reads by number, so a variant is never renumbered or removed. +/// Non-exhaustive — match with a wildcard arm, and prefer [`is_damage`](Self::is_damage) to +/// enumerating the reasons yourself. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +#[non_exhaustive] +pub enum SkippedFilterScan { + /// The image the header describes is larger than this reader's byte budget, so the walk + /// declined to inflate a stream a decode would refuse to allocate. **Nothing is known to be + /// wrong with the file** — it may be a perfectly sound very large PNG. + OverBudget = 0, + /// The IDAT stream is not a valid zlib stream, is truncated, or inflates past the length the + /// header implies. + CorruptStream = 1, + /// The stream inflated, but to a different length than the header implies, so the scanline + /// boundaries it describes are not where the filter bytes are. + LengthMismatch = 2, + /// A scanline's leading byte is not one of the five filter codes §9.1 defines. + UndefinedFilterCode = 3, +} + +impl SkippedFilterScan { + /// Whether this reason means the **file** is damaged, rather than merely unread. + /// + /// The single source of truth for that question, so no caller has to re-derive it from the + /// variant list. [`OverBudget`](Self::OverBudget) is the only reason that is not damage: it + /// describes the reader's budget, not the file. Every other reason is a statement about the + /// bytes, and a future reason is damage until it says otherwise. + #[must_use] + pub fn is_damage(self) -> bool { + !matches!(self, Self::OverBudget) + } +} + /// Where a PNG's bytes went: a total byte accounting plus the figures an encoder-efficiency /// comparison is built from. Produced by [`deconstruct`]. /// @@ -170,16 +252,14 @@ pub struct PngReport { pub idat_compressed: usize, /// The length that codestream inflates to — the filter-prefixed scanline stream. Derived from /// IHDR alone (the sum over [`passes`](Self::passes) when interlaced), so it is known even - /// when [`filters`](Self::filters) is `None`. + /// when [`filters`](Self::filters) was skipped. pub filtered_len: usize, /// The reduced images making up the filtered stream: one entry per non-empty Adam7 pass, or /// exactly one entry for a non-interlaced image. pub passes: Vec, - /// Scanlines per filter type, or `None` when the IDAT stream was not inflated: it was corrupt - /// or truncated, it did not inflate to [`filtered_len`](Self::filtered_len), it carried an - /// undefined filter code, or it was larger than the inflation cap. Everything else in this - /// report is available without inflating. - pub filters: Option, + /// Scanlines per filter type, or the reason the IDAT stream was not scanned. Everything else + /// in this report is derived from framing and IHDR, so it survives whatever the reason is. + pub filters: FilterScan, } impl PngReport { @@ -200,8 +280,7 @@ impl PngReport { /// Whether every byte of this file belongs to a complete, undamaged PNG datastream: fully /// classified, no [`SegmentKind::Truncated`] and no [`SegmentKind::Trailer`], every CRC - /// valid, IEND present, and the IDAT stream inflated to exactly - /// [`filtered_len`](Self::filtered_len). + /// valid, IEND present, and nothing damaging found by the filter scan. /// /// A trailer counts against it even though §13.2 lets a *decoder* ignore trailing bytes, /// because [`bits_per_pixel`](Self::bits_per_pixel) divides the whole file by the pixel @@ -209,11 +288,15 @@ impl PngReport { /// comparison has to know they are there. /// /// Independent of whether every chunk type was *recognised* — an unknown critical chunk is - /// still accounted for. + /// still accounted for. The filter conjunct is + /// [`!filters.is_damage()`](FilterScan::is_damage), not "the scan ran": a stream this reader + /// declined to inflate says nothing against the file, while a corrupt zlib payload under a + /// valid CRC is damage **only** the scan can see, so dropping the conjunct would stop + /// detecting it. #[must_use] pub fn is_intact(&self) -> bool { self.is_fully_classified() - && self.filters.is_some() + && !self.filters.is_damage() && self.segments.iter().all(|segment| match segment.kind { SegmentKind::Truncated | SegmentKind::Trailer => false, SegmentKind::Chunk { crc_ok, .. } => crc_ok, @@ -263,6 +346,23 @@ impl PngReport { self.chunks.iter().map(ChunkStats::framing_bytes).sum() } + /// The decoded image's byte cost — `width × height × channels`, doubled at depth 16 — or + /// `None` when that overflows `usize`. + /// + /// The quantity a decoder budgets, and the one this walk gates its filter scan on, so a + /// [`SkippedFilterScan::OverBudget`] report is exactly one whose `native_bytes` exceeds the + /// reader's budget. Distinct from [`filtered_len`](Self::filtered_len), which adds one filter + /// byte per scanline and counts sub-byte samples packed. + #[must_use] + pub fn native_bytes(&self) -> Option { + ihdr::native_bytes( + self.header.width, + self.header.height, + self.header.color_type.channels(), + self.header.bit_depth, + ) + } + /// The stats for one chunk type, if the file carries it. /// /// A linear scan of [`chunks`](Self::chunks), so it costs O(distinct chunk types) per call — @@ -431,7 +531,7 @@ pub fn deconstruct(png: &[u8]) -> Result { let passes = pass_stats(&native); let filtered_len = adam7::expected_stream_len(&native).unwrap_or(0); - let filters = filter_histogram(&native, &idat, filtered_len, &passes); + let filters = scan_filters(&native, &idat, filtered_len, &passes); Ok(PngReport { file_len: png.len(), @@ -505,32 +605,41 @@ fn fits_decode_budget(header: &ihdr::Ihdr, max_image_bytes: usize) -> bool { /// Inflates the IDAT stream and counts the filter byte leading each scanline. /// -/// `None` whenever the count cannot be trusted: the stream is over budget, corrupt, truncated, -/// inflates to the wrong length, or carries a code §9.1 does not define. Every other figure in -/// the report is derived from framing and IHDR, so it survives all of these. -fn filter_histogram( +/// Every early return names its own reason, so a caller can tell a file this reader declined to +/// inflate from one whose compressed data is broken. Every other figure in the report is derived +/// from framing and IHDR, so it survives all of these. +fn scan_filters( header: &ihdr::Ihdr, idat: &[u8], filtered_len: usize, passes: &[PassStats], -) -> Option { +) -> FilterScan { if !fits_decode_budget(header, DEFAULT_MAX_IMAGE_BYTES) { - return None; + return FilterScan::Skipped(SkippedFilterScan::OverBudget); } - let stream = inflate::inflate_zlib(idat, filtered_len).ok()?; + let Ok(stream) = inflate::inflate_zlib(idat, filtered_len) else { + return FilterScan::Skipped(SkippedFilterScan::CorruptStream); + }; if stream.len() != filtered_len { - return None; + return FilterScan::Skipped(SkippedFilterScan::LengthMismatch); } let mut counts = [0u32; 5]; let mut at = 0usize; for pass in passes { for _ in 0..pass.height { - let filter = FilterType::from_code(*stream.get(at)?)?; + // The pass geometry sums to `filtered_len`, which the stream just matched, so this + // index is in range; a mismatch between the two is the same defect as a short stream. + let Some(&code) = stream.get(at) else { + return FilterScan::Skipped(SkippedFilterScan::LengthMismatch); + }; + let Some(filter) = FilterType::from_code(code) else { + return FilterScan::Skipped(SkippedFilterScan::UndefinedFilterCode); + }; counts[filter as usize] += 1; at += 1 + pass.row_bytes; } } - Some(FilterHistogram { counts }) + FilterScan::Counted(FilterHistogram { counts }) } #[cfg(test)] @@ -563,7 +672,7 @@ mod tests { idat_compressed: 0, filtered_len: 0, passes: Vec::new(), - filters: None, + filters: FilterScan::Skipped(SkippedFilterScan::CorruptStream), } } @@ -627,6 +736,52 @@ mod tests { )); } + #[test] + fn only_an_over_budget_scan_is_not_damage() { + // The single source of truth for `is_intact`'s filter conjunct: declining to inflate a + // stream is a statement about this reader's budget, everything else about the file. + assert!(!SkippedFilterScan::OverBudget.is_damage()); + for reason in [ + SkippedFilterScan::CorruptStream, + SkippedFilterScan::LengthMismatch, + SkippedFilterScan::UndefinedFilterCode, + ] { + assert!(reason.is_damage(), "{reason:?}"); + assert!(FilterScan::Skipped(reason).is_damage(), "{reason:?}"); + } + assert!(!FilterScan::Skipped(SkippedFilterScan::OverBudget).is_damage()); + let counted = FilterScan::Counted(FilterHistogram { + counts: [1, 0, 0, 0, 0], + }); + assert!(!counted.is_damage(), "a scan that ran is never damage"); + } + + #[test] + fn a_filter_scan_exposes_exactly_one_of_its_two_sides() { + // Built here because `FilterHistogram`'s counts are private, so the `Counted` side is + // only constructible from inside the crate. + let histogram = FilterHistogram { + counts: [1, 2, 0, 0, 0], + }; + let counted = FilterScan::Counted(histogram); + assert_eq!(counted.histogram(), Some(histogram)); + assert_eq!(counted.skipped(), None); + + let skipped = FilterScan::Skipped(SkippedFilterScan::OverBudget); + assert_eq!(skipped.histogram(), None); + assert_eq!(skipped.skipped(), Some(SkippedFilterScan::OverBudget)); + } + + #[test] + fn the_skip_reasons_keep_their_published_discriminants() { + // `#[repr(u8)]` plain data crossing the C ABI: these numbers are permanent and + // append-only, so a variant is never renumbered or removed, only added after the last. + assert_eq!(SkippedFilterScan::OverBudget as u8, 0); + assert_eq!(SkippedFilterScan::CorruptStream as u8, 1); + assert_eq!(SkippedFilterScan::LengthMismatch as u8, 2); + assert_eq!(SkippedFilterScan::UndefinedFilterCode as u8, 3); + } + #[test] fn an_overlap_is_not_fully_classified() { assert!(!report_with(&[(0, 20), (10, 33)], 33).is_fully_classified()); diff --git a/crates/gamut-png/src/lib.rs b/crates/gamut-png/src/lib.rs index 09e487cf..bfa13e56 100644 --- a/crates/gamut-png/src/lib.rs +++ b/crates/gamut-png/src/lib.rs @@ -76,7 +76,8 @@ pub use decoded::{ }; pub use decoder::{PngDecoder, TransparencyKey, metadata}; pub use deconstruct::{ - ChunkStats, FilterHistogram, PassStats, PngReport, Segment, SegmentKind, deconstruct, + ChunkStats, FilterHistogram, FilterScan, PassStats, PngReport, Segment, SegmentKind, + SkippedFilterScan, deconstruct, }; pub use encoder::PngEncoder; pub use filter::{FilterStrategy, FilterType}; diff --git a/crates/gamut-png/tests/accounting.rs b/crates/gamut-png/tests/accounting.rs index b463b609..8cdd666e 100644 --- a/crates/gamut-png/tests/accounting.rs +++ b/crates/gamut-png/tests/accounting.rs @@ -13,7 +13,8 @@ use std::time::Instant; use gamut_core::{Dimensions, EncodeImage, ImageRef, Rgb8, Rgba8}; use gamut_png::{ - ChunkStats, FilterStrategy, FilterType, PngEncoder, Segment, SegmentKind, deconstruct, + ChunkStats, FilterScan, FilterStrategy, FilterType, PngEncoder, Segment, SegmentKind, + SkippedFilterScan, deconstruct, }; /// Folds over the segments asserting: non-empty, first starts at 0, each end chains to the next @@ -370,6 +371,7 @@ fn the_filter_histogram_matches_the_filter_libpng_was_forced_to_use() { let report = deconstruct(&png).expect("deconstruct"); let filters = report .filters + .histogram() .expect("a sound IDAT stream yields a histogram"); assert_eq!(filters.total(), 14, "one filter byte per scanline"); @@ -400,7 +402,7 @@ fn the_histogram_walks_each_scanline_not_the_first_one_repeatedly() { .expect("encode"); let report = deconstruct(&png).expect("deconstruct"); - let h = report.filters.expect("sound stream"); + let h = report.filters.histogram().expect("sound stream"); assert_eq!(h.total(), SIDE, "one filter byte per scanline"); let used = [ @@ -440,7 +442,7 @@ fn interlaced_filtered_length_is_the_per_pass_sum() { let rows: u32 = report.passes.iter().map(|p| p.height).sum(); assert_eq!( - report.filters.expect("sound stream").total(), + report.filters.histogram().expect("sound stream").total(), rows, "{w}x{h}: one filter byte per scanline of every non-empty pass" ); @@ -474,7 +476,15 @@ fn a_corrupt_zlib_stream_with_a_valid_crc_yields_no_histogram() { }), "every CRC is valid in this fixture" ); - assert_eq!(report.filters, None, "the histogram is the only casualty"); + assert_eq!( + report.filters, + FilterScan::Skipped(SkippedFilterScan::CorruptStream), + "the scan is the only casualty, and it names why" + ); + assert!( + report.filters.is_damage(), + "a corrupt payload is damage, not a budget refusal" + ); assert!(!report.is_intact()); // Framing- and IHDR-derived figures are unaffected. assert_eq!(report.header.width, 16); @@ -484,19 +494,62 @@ fn a_corrupt_zlib_stream_with_a_valid_crc_yields_no_histogram() { #[test] fn an_over_budget_image_reports_everything_but_the_histogram() { - // A hand-built IHDR claiming 2^30 x 2^30 with a tiny IDAT: the filtered stream it implies is - // far past the inflation cap, so the walk must decline to inflate rather than try. Without - // this the cap comparison is never exercised. + // A hand-built IHDR claiming 2^30 x 2^30 with a tiny IDAT: the image it implies is far past + // the decoder's byte budget, so the walk must decline to inflate rather than try. Without + // this the budget comparison is never exercised. let png = common::png_with_huge_ihdr(); let report = deconstruct(&png).expect("an oversized header is reported, not an error"); assert_covers(&report.segments, png.len()); - assert_eq!(report.filters, None, "declined: over the inflation cap"); + assert_eq!( + report.filters, + FilterScan::Skipped(SkippedFilterScan::OverBudget), + "declined: over the decoder's byte budget" + ); assert!( - report.filtered_len > (64 << 20), - "the implied stream is huge" + report.native_bytes().expect("representable") > (64 << 20), + "the implied image is huge" ); assert_eq!(report.header.width, 1 << 30); + // And so this file is *not* reported as damaged: nothing here can tell whether its IDAT is + // sound, and no decoder in the workspace could read it either, so claiming damage would be + // claiming knowledge the walk does not have. + assert!(!report.filters.is_damage()); + assert!(report.is_intact(), "{report:?}"); +} + +/// An image exactly at the decoder's byte budget must still be scanned. +/// +/// 4096x4096 RGBA8 is 67 108 864 native bytes — the default budget to the byte — but 67 112 960 +/// *filtered*, one more per scanline. A budget stated over the filtered stream therefore declined +/// it, and `is_intact` reported an image the decoder decodes as damaged. Cheap despite the +/// dimensions: nothing allocates `filtered_len`, and the 16-byte IDAT stops the scan at the +/// length check, so the reason is `LengthMismatch` — the file was scanned — and never +/// `OverBudget`. +#[test] +fn an_image_at_the_decoders_byte_budget_is_still_scanned() { + let png = common::png_from_chunks(&[ + common::chunk(b"IHDR", &common::ihdr_payload(4096, 4096, 8, 6, 0)), + common::chunk(b"IDAT", &common::zlib(&[0u8; 16])), + common::chunk(b"IEND", &[]), + ]); + let report = deconstruct(&png).expect("deconstruct"); + + assert_eq!( + report.native_bytes(), + Some(64 << 20), + "exactly the decoder's default budget" + ); + assert!( + report.filtered_len > 64 << 20, + "and past it once the filter bytes are counted: {}", + report.filtered_len + ); + assert_eq!( + report.filters, + FilterScan::Skipped(SkippedFilterScan::LengthMismatch), + "scanned, and stopped by this file's short stream — not declined for budget" + ); } /// A header whose filtered stream overflows `usize` still reports, and its ratio is finite. @@ -593,5 +646,8 @@ fn a_brute_force_encode_still_accounts_and_reports_its_filters() { let report = deconstruct(&png).expect("deconstruct"); assert_covers(&report.segments, png.len()); assert!(report.is_intact()); - assert_eq!(report.filters.expect("sound stream").total(), 32); + assert_eq!( + report.filters.histogram().expect("sound stream").total(), + 32 + ); } From 0d680a8ffef722007e6d68bcf84745c8cd5fc2c1 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 19:27:00 -0400 Subject: [PATCH 28/54] docs(cli): state what inspect's exit code means per format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The module doc said the command exits non-zero when the file "is not fully accounted for" without saying what that is, and the three formats name it differently: TIFF and DNG gate on `is_fully_accounted()`, PNG on `is_intact()`. They are the same strength, which is worth writing down — PNG's `is_fully_classified()` is printed but is not the gate, being true by construction for every file `deconstruct` accepts, so gating on it would exit 0 on a truncated PNG. Also records that an over-budget filter scan is not a finding, and moves the stray `/// The display name of a format.` off `inspect_png` and back onto `format_name`. --- crates/gamut-cli/src/commands/inspect.rs | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/crates/gamut-cli/src/commands/inspect.rs b/crates/gamut-cli/src/commands/inspect.rs index f8be1aa3..b53b6120 100644 --- a/crates/gamut-cli/src/commands/inspect.rs +++ b/crates/gamut-cli/src/commands/inspect.rs @@ -5,6 +5,24 @@ //! Prints a report to stdout and exits non-zero when the file is not fully accounted for — //! usable as an archival CI gate. //! +//! # What "fully accounted for" means, and what the exit code is +//! +//! Exit 0 is the file having nothing the walk can hold against it; exit 1 is a finding. Each +//! format states that in its own vocabulary, and the two are deliberately the same strength: +//! +//! - **TIFF / DNG** — `is_fully_accounted()`: every byte classified, *and* no unknown field +//! type, no unknown tag, and no anomaly. +//! - **PNG** — `is_intact()`: every byte classified, *and* every chunk CRC valid, IEND present, +//! no trailing bytes after it, no truncated tail, and nothing the filter scan found damaging. +//! +//! PNG's `is_fully_classified()` is **not** the gate, though it is printed: it is true by +//! construction for every file `deconstruct` accepts (a truncated tail and a trailer each get a +//! segment of their own, so the tiling still covers the file), and gating on it would exit 0 on a +//! truncated PNG. It exists so that a walk *bug* makes the predicate false. +//! +//! A PNG whose filter scan was skipped only because the image is larger than this reader's byte +//! budget is not a finding: nothing is known to be wrong with it. +//! //! For PNG the same walk answers a second question: **where did the bytes go?** The report carries //! the per-chunk-type breakdown, the compressed IDAT total against the filtered stream it inflates //! to, and the scanline filter distribution — which is what makes an encoder comparison possible @@ -360,7 +378,6 @@ fn print_lines(label: &str, lines: &[String]) { } } -/// The display name of a format. /// Deconstructs a PNG and prints where its bytes went, exiting non-zero when the file is not a /// complete, undamaged datastream. fn inspect_png(path: &std::path::Path, data: &[u8]) -> Result<(), CliError> { @@ -513,6 +530,7 @@ fn filter_skip_label(reason: gamut::png::SkippedFilterScan) -> &'static str { } } +/// The display name of a format. fn format_name(format: Format) -> &'static str { match format { Format::Tiff => "TIFF", From e8588186dd31b397283d760d58edd22b7b026493 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 19:27:59 -0400 Subject: [PATCH 29/54] refactor(png): delete choose_min_sum_abs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It is dead in the shipped crate — the encoder calls `choose_by` directly, and the wrapper carried `allow(dead_code)` off the `test-support` feature to say so. What it added on top of `choose_by` was a fresh 9 KiB `Scratch` per call, which `Score::SumAbs` never reads: the bench row it existed to serve was therefore measuring a per-scanline allocation the encoder never performs, and its question — what the sum-of-absolute-residuals heuristic costs per row — is already answered by the `filter_image / MinSumAbs` row. It was also a wrapper body in a seam whose own module doc forbids them: `stages` is "re-exports and nothing else", because bench targets are reached by no gate, so a body there drags the coverage floor and generates mutants nothing can kill. Its one test moves to `choose_by(Score::SumAbs, ...)`, the call the encoder actually makes, and keeps its teeth: inverting `choose_by`'s comparison still fails it. --- crates/gamut-png/benches/encode.rs | 13 --------- crates/gamut-png/src/filter.rs | 47 +++++++++--------------------- crates/gamut-png/src/stages.rs | 2 +- 3 files changed, 15 insertions(+), 47 deletions(-) diff --git a/crates/gamut-png/benches/encode.rs b/crates/gamut-png/benches/encode.rs index 1971964d..d19351c1 100644 --- a/crates/gamut-png/benches/encode.rs +++ b/crates/gamut-png/benches/encode.rs @@ -371,19 +371,6 @@ mod stages { .bench_local(|| stages::filter_image(black_box(strategy), &samples, ROW_BYTES, BPP)); } - /// The per-scanline heuristic in isolation: five trial filterings plus five scorings, per row. - #[divan::bench(args = [1usize, 3, 4])] - fn choose_min_sum_abs(bencher: Bencher, bpp: usize) { - let row: Vec = (0..ROW_BYTES).map(|i| (i * 7) as u8).collect(); - let prev: Vec = (0..ROW_BYTES).map(|i| (i * 13 + 5) as u8).collect(); - bencher - .counter(BytesCount::new(row.len())) - .with_inputs(|| (Vec::new(), Vec::new())) - .bench_local_refs(|(scratch, best): &mut (Vec, Vec)| { - stages::choose_min_sum_abs(&row, &prev, black_box(bpp), scratch, best) - }); - } - #[divan::bench(args = [1u8, 2, 4])] fn pack_scanlines(bencher: Bencher, depth: u8) { let samples = vec![1u8; (SIDE * SIDE) as usize]; diff --git a/crates/gamut-png/src/filter.rs b/crates/gamut-png/src/filter.rs index 2982517b..97ad26c6 100644 --- a/crates/gamut-png/src/filter.rs +++ b/crates/gamut-png/src/filter.rs @@ -322,43 +322,16 @@ pub fn filter_image( out } -/// Picks the filter with the lowest sum-of-absolute-residuals for one scanline, leaving that -/// filter's bytes in `best_bytes`. -/// -/// Returning the winning bytes rather than just the winning filter is what makes this five passes -/// over the row instead of six: the caller would otherwise re-run [`filter_row`] for the filter -/// just chosen, having already computed exactly those bytes and thrown them away. Keeping them -/// costs one `memcpy` per improvement, against a full filter pass per scanline. -#[cfg_attr( - not(feature = "test-support"), - allow( - dead_code, - reason = "the benchmark stage seam's entry point; see crate::stages" - ) -)] -pub fn choose_min_sum_abs( - cur: &[u8], - prev: &[u8], - bpp: usize, - scratch: &mut Vec, - best_bytes: &mut Vec, -) -> FilterType { - choose_by( - Score::SumAbs, - cur, - prev, - bpp, - scratch, - best_bytes, - &mut Scratch::new(), - ) -} - /// Tries all five filters and keeps the one `kind` ranks lowest, leaving its bytes in /// `best_bytes`. /// /// The first minimum wins, so a tie resolves to the earlier filter in None/Sub/Up/Average/Paeth /// order — deterministic, which the byte-reproducibility contract depends on. +/// +/// Returning the winning bytes in `best_bytes` rather than just the winning filter is what makes +/// this five passes over the row instead of six: the caller would otherwise re-run [`filter_row`] +/// for the filter just chosen, having already computed exactly those bytes and thrown them away. +/// Keeping them costs one `memcpy` per improvement, against a full filter pass per scanline. fn choose_by( kind: Score, cur: &[u8], @@ -579,7 +552,15 @@ mod tests { // scores far below None. let row: Vec = (0..30u8).map(|i| i.wrapping_mul(3)).collect(); let prev = vec![0u8; row.len()]; - let chosen = choose_min_sum_abs(&row, &prev, 1, &mut Vec::new(), &mut Vec::new()); + let chosen = choose_by( + Score::SumAbs, + &row, + &prev, + 1, + &mut Vec::new(), + &mut Vec::new(), + &mut Scratch::new(), + ); assert_eq!(chosen, FilterType::Sub); } } diff --git a/crates/gamut-png/src/stages.rs b/crates/gamut-png/src/stages.rs index a9d207db..b8a4c87f 100644 --- a/crates/gamut-png/src/stages.rs +++ b/crates/gamut-png/src/stages.rs @@ -17,6 +17,6 @@ //! bodies), so it carries no logic of its own to mutate." pub use crate::crc32::Crc32; -pub use crate::filter::{choose_min_sum_abs, filter_image}; +pub use crate::filter::filter_image; pub use crate::pack::pack_scanlines; pub use crate::reduce::{Reduced, analyze8, analyze16}; From 9ca0f19b4d9b0fbfe2683237c15ab3e68f9f2070 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 19:43:07 -0400 Subject: [PATCH 30/54] test(png): derive every size budget from its measurement The table's ratios were chosen by hand, so nothing said what a budget meant or when it should move. Each `max_ratio` is now `measured` times a stated headroom, rounded up to two decimals, and `Budget::max_ratio` carries the procedure for refreshing the whole table after an encoder change. The refresh also adds the three rows the bench reported and nothing gated: both `+clean` columns and `tiny_rgb8`. `Budget` grows `fixture`, `side` and `cleanup` so a cleaned row shares its twin's pixels instead of duplicating them. Two rows take less than the default 5%. `sprite_rgba8` measures 0.963, where 5% rounds past 1.00 and would surrender the claim the row exists to make, so it takes 2%. `palette64_rgba8 +clean` takes 2% because there is nothing to protect: cleaning *costs* bytes there, 403 against the uncleaned 364. That last row's justification had it backwards -- it predicted shorter PLTE and tRNS and therefore a smaller file. Both halves of that are true and the file still grows, because collapsing the transparent entries rewrites pixels that were compressing well and at 128x128 the second effect wins. `with_transparent_cleanup` is a canonicalisation, not an optimisation. The row now says so, which is the drift this refresh exists to catch. The gradient and photo rows move on their own: 0.939 to 0.772 and 0.752 to 0.731, from this branch's encoder work. Refs #224 --- crates/gamut-png/tests/size_contract.rs | 198 ++++++++++++++++++------ 1 file changed, 151 insertions(+), 47 deletions(-) diff --git a/crates/gamut-png/tests/size_contract.rs b/crates/gamut-png/tests/size_contract.rs index 0a774b96..5401bf04 100644 --- a/crates/gamut-png/tests/size_contract.rs +++ b/crates/gamut-png/tests/size_contract.rs @@ -19,77 +19,164 @@ use gamut_png::{FilterStrategy, Level, PngEncoder, deconstruct}; /// One case's size budget against libpng at zlib level 9. struct Budget { - /// Corpus entry name; matches `benches/encode.rs`. + /// Row label; matches `benches/encode.rs`, plus a `+clean` suffix where this row differs from + /// its neighbour only by [`PngEncoder::with_transparent_cleanup`]. name: &'static str, + /// Corpus generator key. Distinct from `name` so a cleaned row can share a fixture with its + /// uncleaned twin rather than duplicating the pixels. + fixture: &'static str, + /// The square side to measure at. + side: u32, + /// Whether to enable [`PngEncoder::with_transparent_cleanup`]. + cleanup: bool, /// The most gamut's file may measure as a fraction of libpng's. `1.00` reads "never larger". + /// + /// Derived, not chosen: `measured × (1 + headroom)` rounded up to two decimals, where the + /// headroom is 5% unless this row's `why` names the other component whose drift it absorbs. max_ratio: f64, - /// What the case measured when the budget was set, so drift is visible in review. + /// What the case actually measures at this revision, so drift is visible in review. + /// + /// To refresh the whole table after an encoder change: set every `max_ratio` to `2.00`, run + /// `cargo test -p gamut-png --test size_contract + /// gamut_never_exceeds_its_size_budget_against_libpng9 -- --exact --nocapture`, paste each + /// printed ratio back into `measured`, then re-derive `max_ratio` by the rule above. measured: f64, /// Why this number and not a tighter one — which stage spends the bytes. why: &'static str, } -/// Every budget carries its justification. Measured at 128x128 (half the bench's side, so the -/// suite stays quick enough for the coverage and mutation lanes); the ratios track the bench's -/// 256x256 figures closely but are not identical, which is why they are recorded separately. +/// Every budget carries its justification, and every `max_ratio` is derived from the `measured` +/// beside it rather than chosen -- see [`Budget::max_ratio`]. +/// +/// Measured at 128x128 (a quarter of the bench's pixel count, so the suite stays quick enough for +/// the coverage and mutation lanes) except `tiny_rgb8`, which is the bench's own 16x16 row. +/// +/// These ratios are **not** comparable with the bench's 256x256 figures and must be read +/// separately. Every fixed cost -- the signature, IHDR, PLTE/tRNS, IEND, and DEFLATE's own framing +/// -- is amortised over a quarter as many pixels here, which systematically disadvantages exactly +/// the rows where a reduction wins: the gap runs to about 30 percentage points on `gradient_rgb8` +/// and `palette64_rgba8`. `STATUS.md` records the 256x256 table; this one gates. const BUDGETS: &[Budget] = &[ Budget { name: "gradient_rgb8", - max_ratio: 0.98, - measured: 0.939, + fixture: "gradient_rgb8", + side: 128, + cleanup: false, + max_ratio: 0.82, + measured: 0.772, why: "no reduction applies, so this is filtering plus DEFLATE against libpng's own \ adaptive filtering. The margin is thin by nature -- both encoders are doing the \ same job -- so the budget only guards against losing outright.", }, Budget { name: "photo_rgb8", - max_ratio: 0.85, - measured: 0.752, + fixture: "photo_rgb8", + side: 128, + cleanup: false, + max_ratio: 0.83, + measured: 0.731, why: "smooth photographic content: palette-hostile, so again pure filtering + DEFLATE, \ and the win is the optimal parse. Coupled to gamut-deflate's own Best/z9 column by \ - construction: if that regresses, this row moves with it. Headroom is wider than \ - the others for that reason.", + construction: if that regresses, this row moves with it, so it carries 13% headroom \ + where the others carry 5%.", }, Budget { name: "noise_rgb8", - max_ratio: 1.01, + fixture: "noise_rgb8", + side: 128, + cleanup: false, + max_ratio: 1.02, measured: 0.998, why: "incompressible, so both encoders fall back to stored blocks and the file is \ slightly larger than the raw samples. Above 1.0 because there is nothing to win \ - here, not because we lose; the margin covers stored-block framing only.", + here, not because we lose; the 2% margin covers stored-block framing only.", }, Budget { name: "grey_as_rgb8", - max_ratio: 0.70, + fixture: "grey_as_rgb8", + side: 128, + cleanup: false, + max_ratio: 0.62, measured: 0.582, why: "R=G=B everywhere, so auto-reduce drops two channels before DEFLATE runs. A \ structural win libpng does not attempt.", }, Budget { name: "flat_rgba8", - max_ratio: 0.45, + fixture: "flat_rgba8", + side: 128, + cleanup: false, + max_ratio: 0.36, measured: 0.321, why: "one opaque colour: the reduce cascade collapses it to depth-1 indexed, and chunk \ - framing is most of what remains.", + framing is most of what remains. 10% headroom because at ~100 bytes total a single \ + byte moves the ratio by about a percent.", }, Budget { name: "sprite_rgba8", - max_ratio: 1.00, + fixture: "sprite_rgba8", + side: 128, + cleanup: false, + max_ratio: 0.99, measured: 0.963, - why: "binary alpha over invisible colour noise. Deliberately loose: the reduce cascade \ - does not reach this case today -- no tRNS colour key, no dirty-alpha cleaning -- so \ - the margin is thin. Tightening it is the acceptance test for those two axes.", + why: "binary alpha over invisible colour noise. The reduce cascade now reaches this \ + case -- `write_reduced_or_native` races an `RGB`+`tRNS` colour key against the \ + unreduced encoding and keeps whichever is smaller -- so the budget is a real one \ + rather than the placeholder 1.00 it carried while those axes were missing. \ + Tightening it was #481's stated acceptance test. 2% headroom, not 5%: at 0.963 the \ + usual 5% rounds past 1.00, which would give up the very claim this row exists to \ + make.", + }, + Budget { + name: "sprite_rgba8 +clean", + fixture: "sprite_rgba8", + side: 128, + cleanup: true, + max_ratio: 0.70, + measured: 0.665, + why: "the same pixels with `with_transparent_cleanup`, which collapses every invisible \ + pixel to one colour and so makes the palette reachable. This is the row that gates \ + the `+clean` column STATUS.md publishes; without it the headline cleanup result was \ + measured by a bench and asserted by nothing.", }, Budget { name: "palette64_rgba8", - max_ratio: 1.00, - measured: 0.963, + fixture: "palette64_rgba8", + side: 128, + cleanup: false, + max_ratio: 0.95, + measured: 0.899, why: "64 colours over two alpha levels. The palette encoding wins outright at 256x256 \ but loses at this size, because PLTE + tRNS is a flat 273 incompressible bytes \ against pixels that compress ~160x; `write_reduced_or_native` encodes both and \ - keeps the smaller, so the row measures whichever is actually better here rather \ - than whichever the raw-size estimate preferred. Budgeted at 1.00 rather than \ - tighter precisely because which candidate wins is size-dependent.", + keeps the smaller, so the row measures whichever is actually better here. The race \ + is what makes the outcome stable enough to budget below 1.00.", + }, + Budget { + name: "palette64_rgba8 +clean", + fixture: "palette64_rgba8", + side: 128, + cleanup: true, + max_ratio: 1.02, + measured: 0.995, + why: "cleaning *costs* bytes here -- 403 against the uncleaned row's 364 -- and that is \ + the point of the row. Collapsing the transparent entries does shorten PLTE and \ + tRNS, but it also rewrites pixels that were compressing well, and at 128x128 the \ + second effect wins. `with_transparent_cleanup` is a canonicalisation, not an \ + optimisation, and this is the case that says so out loud; the same trade is \ + asserted directly by `a_colour_key_can_lose_the_size_race`. 2% headroom for the \ + same reason as `noise_rgb8`: there is no win here to protect.", + }, + Budget { + name: "tiny_rgb8", + fixture: "tiny_rgb8", + side: 16, + cleanup: false, + max_ratio: 0.95, + measured: 0.862, + why: "the regime where the signature and five chunks of framing dominate, and the only \ + row where `overhead_bytes` is legible. Reported by the bench and, until now, gated \ + by nothing. Same 10% headroom as `flat_rgba8`, for the same reason.", }, ]; @@ -97,9 +184,8 @@ const BUDGETS: &[Budget] = &[ const SIDE: u32 = 128; /// The pixels for a budget row, and how many channels they carry. -fn pixels(name: &str) -> (Vec, usize) { - let side = SIDE; - match name { +fn pixels(fixture: &str, side: u32) -> (Vec, usize) { + match fixture { "gradient_rgb8" => (common::corpus::gradient_rgb(side), 3), "photo_rgb8" => (common::corpus::photo_rgb(side), 3), "noise_rgb8" => (common::corpus::noise_rgb(side), 3), @@ -107,17 +193,24 @@ fn pixels(name: &str) -> (Vec, usize) { "palette64_rgba8" => (common::corpus::palette64_rgba(side), 4), "sprite_rgba8" => (common::corpus::sprite_rgba(side), 4), "flat_rgba8" => (common::corpus::flat_rgba(side), 4), - other => panic!("unknown budget row {other}"), + // The bench's 16x16 row: the regime where chunk framing dominates bits-per-pixel. + "tiny_rgb8" => (common::corpus::gradient_rgb(side), 3), + other => panic!("unknown corpus fixture {other}"), } } /// Encodes at the crate's smallest-output settings. -fn gamut_best(samples: &[u8], channels: usize) -> Vec { +/// +/// `BruteForce`'s candidate set is integer-only -- `MinEntropy` is deliberately not in it -- so no +/// `f64::log2` enters the gated path and these ratios are machine-independent as well as stable +/// run to run. +fn gamut_best(samples: &[u8], channels: usize, side: u32, cleanup: bool) -> Vec { let encoder = PngEncoder::new() .with_compression(Level::Best) .with_filter(FilterStrategy::BruteForce) - .with_auto_reduce(true); - let dims = Dimensions::new(SIDE, SIDE).expect("valid dimensions"); + .with_auto_reduce(true) + .with_transparent_cleanup(cleanup); + let dims = Dimensions::new(side, side).expect("valid dimensions"); let mut out = Vec::new(); if channels == 3 { let image = ImageRef::::new(samples, dims).expect("buffer matches dimensions"); @@ -131,7 +224,7 @@ fn gamut_best(samples: &[u8], channels: usize) -> Vec { /// The same source layout through libpng at zlib level 9 — no palette hint, default adaptive /// filtering. Handing libpng a palette would hand it gamut's own reduction. -fn libpng9(samples: &[u8], channels: usize) -> Vec { +fn libpng9(samples: &[u8], channels: usize, side: u32) -> Vec { let color_type = if channels == 3 { libpng_oracle::COLOR_RGB } else { @@ -139,8 +232,8 @@ fn libpng9(samples: &[u8], channels: usize) -> Vec { }; libpng_oracle::encode( samples, - SIDE, - SIDE, + side, + side, color_type, 8, &libpng_oracle::EncodeOpts { @@ -153,10 +246,20 @@ fn libpng9(samples: &[u8], channels: usize) -> Vec { #[test] fn gamut_never_exceeds_its_size_budget_against_libpng9() { for budget in BUDGETS { - let (samples, channels) = pixels(budget.name); - let ours = gamut_best(&samples, channels); - let theirs = libpng9(&samples, channels); + let (samples, channels) = pixels(budget.fixture, budget.side); + let ours = gamut_best(&samples, channels, budget.side, budget.cleanup); + let theirs = libpng9(&samples, channels, budget.side); let ratio = ours.len() as f64 / theirs.len() as f64; + // Printed, not just asserted: the `measured` column is only honest if refreshing it is a + // paste rather than a re-derivation. `cargo test` captures this on success. + println!( + "{:<22} {:>7} / {:>7} = {ratio:.3} (budget {:.2}, recorded {:.3})", + budget.name, + ours.len(), + theirs.len(), + budget.max_ratio, + budget.measured, + ); assert!( ratio <= budget.max_ratio, "{}: {} bytes vs libpng-9's {} = {ratio:.3}, budget {:.2} (measured {:.2} when set)\n {}", @@ -185,9 +288,9 @@ fn gamut_beats_libpng9_where_it_claims_to() { "palette64_rgba8", ]; for budget in BUDGETS.iter().filter(|b| WINS.contains(&b.name)) { - let (samples, channels) = pixels(budget.name); - let ours = gamut_best(&samples, channels); - let theirs = libpng9(&samples, channels); + let (samples, channels) = pixels(budget.fixture, budget.side); + let ours = gamut_best(&samples, channels, budget.side, budget.cleanup); + let theirs = libpng9(&samples, channels, budget.side); assert!( ours.len() < theirs.len(), "{}: claims a structural win but measured {} vs {}", @@ -205,9 +308,9 @@ fn the_deflate_stage_accounts_for_the_residual_gap() { // construction, so the ratio of the *compressed* streams isolates DEFLATE from filtering and // from the colour-type choice. Only the rows where no reduction applies can say this. for name in ["gradient_rgb8", "photo_rgb8"] { - let (samples, channels) = pixels(name); - let ours = gamut_best(&samples, channels); - let theirs = libpng9(&samples, channels); + let (samples, channels) = pixels(name, SIDE); + let ours = gamut_best(&samples, channels, SIDE, false); + let theirs = libpng9(&samples, channels, SIDE); let (a, b) = ( deconstruct(&ours).expect("gamut output deconstructs"), deconstruct(&theirs).expect("libpng output deconstructs"), @@ -235,9 +338,10 @@ fn the_deflate_stage_accounts_for_the_residual_gap() { fn encoded_size_is_deterministic() { // Without this the budget table is measuring noise rather than the encoder. for budget in BUDGETS { - let (samples, channels) = pixels(budget.name); - let first = gamut_best(&samples, channels); - let second = gamut_best(&samples, channels); + let (samples, channels) = pixels(budget.fixture, budget.side); + let first = gamut_best(&samples, channels, budget.side, budget.cleanup); + let second = gamut_best(&samples, channels, budget.side, budget.cleanup); assert_eq!(first, second, "{}: encode is not reproducible", budget.name); } } + From 8e9f038581235231319914b40b8ec08a68b34c39 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 19:53:07 -0400 Subject: [PATCH 31/54] fix(png): race the cleaned encoding instead of assuming it wins `with_transparent_cleanup` committed to the transform on the assumption that collapsing invisible pixels to one colour can only help. Measured on `palette64_rgba8`, it does not: cleaning is worth -2.3% at 32x32, **+10.7% at 128x128** and -5.2% at 256x256, with both candidates landing on the same colour type throughout. The sign is a property of the image, not of any reduction. The mechanism is that cleaning is a *transform*, not a reduction. It rewrites bytes DEFLATE was already compressing. Where the invisible pixels carry noise -- a sprite -- zeroing them is worth ~31%. Where they carry structure that continues under the transparent region, zeroing inserts a discontinuity that costs more than the collapsed palette saves. This is the same failure `6b31ab9` fixed one axis over for palettes, and it takes the same fix: encode both candidates and keep the smaller, with no tuned constant. `cleaned_or_plain` mirrors `write_reduced_or_native`, and the two per-buffer encodes are factored into `encode_alpha8`/`encode_alpha16` so all four alpha-carrying layouts race identically at both bit depths. A tie keeps the cleaned encoding, which carries less unseen data. `with_transparent_cleanup` now means "clean where it pays" and can never cost bytes. `cleanup_never_costs_bytes_on_any_corpus_row` pins that as a law over every corpus row -- it needs no constant and would have failed before this change -- and `palette64_rgba8 +clean` is the row that exercises the declining side, now measuring exactly what its uncleaned twin does. --- crates/gamut-png/src/encoder.rs | 190 ++++++++++++++---------- crates/gamut-png/tests/size_contract.rs | 45 ++++-- 2 files changed, 150 insertions(+), 85 deletions(-) diff --git a/crates/gamut-png/src/encoder.rs b/crates/gamut-png/src/encoder.rs index 38e3d096..34053a21 100644 --- a/crates/gamut-png/src/encoder.rs +++ b/crates/gamut-png/src/encoder.rs @@ -372,6 +372,92 @@ impl PngEncoder { ) } + /// Encodes one 8-bit alpha-carrying sample buffer: the auto-reduce race if it applies, the + /// plain layout otherwise. + /// + /// Split out of the `EncodeImage` impls so [`cleaned_or_plain`](Self::cleaned_or_plain) can + /// run it twice over two different sample buffers. + fn encode_alpha8( + &self, + dims: Dimensions, + samples: &[u8], + channels: usize, + color: ColorType, + out: &mut Vec, + ) -> Result { + if self.auto_reduce + && let Some(reduced) = reduce::analyze8(samples, channels) + { + return self.write_reduced_or_native( + dims, + reduced, + |o| self.write_png((dims.width, dims.height), samples, color, 8, |_| {}, o), + out, + ); + } + self.write_png((dims.width, dims.height), samples, color, 8, |_| {}, out) + } + + /// The 16-bit twin of [`encode_alpha8`](Self::encode_alpha8). + fn encode_alpha16( + &self, + dims: Dimensions, + samples: &[u16], + channels: usize, + color: ColorType, + out: &mut Vec, + ) -> Result { + if self.auto_reduce + && let Some(reduced) = reduce::analyze16(samples, channels) + { + return self.write_reduced_or_native( + dims, + reduced, + |o| self.encode_16bit(dims, samples, color, o), + out, + ); + } + self.encode_16bit(dims, samples, color, out) + } + + /// Encodes the image both ways when cleaning changed something, and keeps the smaller file. + /// + /// Cleaning collapses every invisible pixel to one colour, which is what makes a palette or a + /// colour key reachable at all — worth ~31% on a sprite whose invisible pixels carry noise. + /// But it is a *transform*, not a reduction: it rewrites bytes DEFLATE was already + /// compressing. Where the invisible pixels carry structure — a gradient that continues under + /// the transparent region — zeroing them inserts a discontinuity that costs more than the + /// collapsed palette saves. Measured on `palette64_rgba8`, cleaning is worth −2.3% at 32x32, + /// **+10.7% at 128x128** and −5.2% at 256x256, with both candidates landing on the same + /// colour type throughout: the sign genuinely depends on the image. + /// + /// So the choice is raced rather than assumed, exactly as + /// [`write_reduced_or_native`](Self::write_reduced_or_native) races a palette against the + /// unreduced encoding, and for the same reason: no tuned constant can predict a compressed + /// size. [`with_transparent_cleanup`](Self::with_transparent_cleanup) therefore means "clean + /// where it pays", and enabling it can never cost bytes. + /// + /// A tie keeps the cleaned encoding, which carries less unseen data. + fn cleaned_or_plain( + &self, + cleaned: impl FnOnce(&mut Vec) -> Result, + plain: impl FnOnce(&mut Vec) -> Result, + out: &mut Vec, + ) -> Result { + let mut cleaned_encoding = Vec::new(); + cleaned(&mut cleaned_encoding)?; + let mut plain_encoding = Vec::new(); + plain(&mut plain_encoding)?; + + let winner = if plain_encoding.len() < cleaned_encoding.len() { + plain_encoding + } else { + cleaned_encoding + }; + out.extend_from_slice(&winner); + Ok(winner.len()) + } + /// The cleaned samples, or `None` to use the caller's buffer unchanged — either because the /// knob is off or because the image has no fully transparent pixel. fn cleaned_samples(&self, samples: &[u8], channels: usize) -> Option> { @@ -739,70 +825,30 @@ impl EncodeImage for PngEncoder { } impl EncodeImage for PngEncoder { fn encode_image(&self, image: ImageRef<'_, Rgba8>, out: &mut Vec) -> Result { - let cleaned = self.cleaned_samples(image.as_samples(), 4); - let samples = cleaned.as_deref().unwrap_or_else(|| image.as_samples()); let dims = image.dimensions(); - if self.auto_reduce - && let Some(reduced) = reduce::analyze8(samples, 4) - { - return self.write_reduced_or_native( - dims, - reduced, - |o| { - self.write_png( - (dims.width, dims.height), - samples, - ColorType::TruecolorAlpha, - 8, - |_| {}, - o, - ) - }, + let plain = image.as_samples(); + match self.cleaned_samples(plain, 4) { + Some(cleaned) => self.cleaned_or_plain( + |o| self.encode_alpha8(dims, &cleaned, 4, ColorType::TruecolorAlpha, o), + |o| self.encode_alpha8(dims, plain, 4, ColorType::TruecolorAlpha, o), out, - ); + ), + None => self.encode_alpha8(dims, plain, 4, ColorType::TruecolorAlpha, out), } - self.write_png( - (dims.width, dims.height), - samples, - ColorType::TruecolorAlpha, - 8, - |_| {}, - out, - ) } } impl EncodeImage for PngEncoder { fn encode_image(&self, image: ImageRef<'_, GrayAlpha8>, out: &mut Vec) -> Result { - let cleaned = self.cleaned_samples(image.as_samples(), 2); - let samples = cleaned.as_deref().unwrap_or_else(|| image.as_samples()); let dims = image.dimensions(); - if self.auto_reduce - && let Some(reduced) = reduce::analyze8(samples, 2) - { - return self.write_reduced_or_native( - dims, - reduced, - |o| { - self.write_png( - (dims.width, dims.height), - samples, - ColorType::GrayscaleAlpha, - 8, - |_| {}, - o, - ) - }, + let plain = image.as_samples(); + match self.cleaned_samples(plain, 2) { + Some(cleaned) => self.cleaned_or_plain( + |o| self.encode_alpha8(dims, &cleaned, 2, ColorType::GrayscaleAlpha, o), + |o| self.encode_alpha8(dims, plain, 2, ColorType::GrayscaleAlpha, o), out, - ); + ), + None => self.encode_alpha8(dims, plain, 2, ColorType::GrayscaleAlpha, out), } - self.write_png( - (dims.width, dims.height), - samples, - ColorType::GrayscaleAlpha, - 8, - |_| {}, - out, - ) } } impl EncodeImage for PngEncoder { @@ -839,38 +885,30 @@ impl EncodeImage for PngEncoder { } impl EncodeImage for PngEncoder { fn encode_image(&self, image: ImageRef<'_, Rgba16>, out: &mut Vec) -> Result { - let cleaned = self.cleaned_samples16(image.as_samples(), 4); - let samples = cleaned.as_deref().unwrap_or_else(|| image.as_samples()); let dims = image.dimensions(); - if self.auto_reduce - && let Some(reduced) = reduce::analyze16(samples, 4) - { - return self.write_reduced_or_native( - dims, - reduced, - |o| self.encode_16bit(dims, samples, ColorType::TruecolorAlpha, o), + let plain = image.as_samples(); + match self.cleaned_samples16(plain, 4) { + Some(cleaned) => self.cleaned_or_plain( + |o| self.encode_alpha16(dims, &cleaned, 4, ColorType::TruecolorAlpha, o), + |o| self.encode_alpha16(dims, plain, 4, ColorType::TruecolorAlpha, o), out, - ); + ), + None => self.encode_alpha16(dims, plain, 4, ColorType::TruecolorAlpha, out), } - self.encode_16bit(dims, samples, ColorType::TruecolorAlpha, out) } } impl EncodeImage for PngEncoder { fn encode_image(&self, image: ImageRef<'_, GrayAlpha16>, out: &mut Vec) -> Result { - let cleaned = self.cleaned_samples16(image.as_samples(), 2); - let samples = cleaned.as_deref().unwrap_or_else(|| image.as_samples()); let dims = image.dimensions(); - if self.auto_reduce - && let Some(reduced) = reduce::analyze16(samples, 2) - { - return self.write_reduced_or_native( - dims, - reduced, - |o| self.encode_16bit(dims, samples, ColorType::GrayscaleAlpha, o), + let plain = image.as_samples(); + match self.cleaned_samples16(plain, 2) { + Some(cleaned) => self.cleaned_or_plain( + |o| self.encode_alpha16(dims, &cleaned, 2, ColorType::GrayscaleAlpha, o), + |o| self.encode_alpha16(dims, plain, 2, ColorType::GrayscaleAlpha, o), out, - ); + ), + None => self.encode_alpha16(dims, plain, 2, ColorType::GrayscaleAlpha, out), } - self.encode_16bit(dims, samples, ColorType::GrayscaleAlpha, out) } } diff --git a/crates/gamut-png/tests/size_contract.rs b/crates/gamut-png/tests/size_contract.rs index 5401bf04..ede488f5 100644 --- a/crates/gamut-png/tests/size_contract.rs +++ b/crates/gamut-png/tests/size_contract.rs @@ -157,15 +157,16 @@ const BUDGETS: &[Budget] = &[ fixture: "palette64_rgba8", side: 128, cleanup: true, - max_ratio: 1.02, - measured: 0.995, - why: "cleaning *costs* bytes here -- 403 against the uncleaned row's 364 -- and that is \ - the point of the row. Collapsing the transparent entries does shorten PLTE and \ - tRNS, but it also rewrites pixels that were compressing well, and at 128x128 the \ - second effect wins. `with_transparent_cleanup` is a canonicalisation, not an \ - optimisation, and this is the case that says so out loud; the same trade is \ - asserted directly by `a_colour_key_can_lose_the_size_race`. 2% headroom for the \ - same reason as `noise_rgb8`: there is no win here to protect.", + max_ratio: 0.95, + measured: 0.899, + why: "the row where cleaning does not pay, and therefore is not done. Collapsing the \ + transparent entries shortens PLTE and tRNS, but it also rewrites pixels that were \ + compressing well, and at 128x128 the second effect wins: cleaning measured 403 \ + bytes against the uncleaned 364. `cleaned_or_plain` races the two and keeps the \ + smaller, so this row now measures exactly what `palette64_rgba8` does, and the \ + budget is the same. That equality is the assertion -- it is what \ + `with_transparent_cleanup` never costing bytes looks like from here, and it is \ + pinned as a law for every row by `cleanup_never_costs_bytes_on_any_corpus_row`.", }, Budget { name: "tiny_rgb8", @@ -345,3 +346,29 @@ fn encoded_size_is_deterministic() { } } + +#[test] +fn cleanup_never_costs_bytes_on_any_corpus_row() { + // The gate on `with_transparent_cleanup`'s central claim. It is only true because the encoder + // *races* the cleaned and uncleaned encodings and keeps the smaller: cleaning is a transform, + // not a reduction, and on a fixture whose invisible pixels carry structure rather than noise + // it destroys compressible bytes. Measured before the race, on `palette64_rgba8`, cleaning was + // worth -2.3% at 32x32, +10.7% at 128x128 and -5.2% at 256x256 -- with both candidates landing + // on the same colour type, so the sign was a property of the image, not of the reduction. + // + // A law rather than a budget, so it covers every row and every side, and needs no constant. + for budget in BUDGETS.iter().filter(|b| !b.cleanup) { + let (samples, channels) = pixels(budget.fixture, budget.side); + let plain = gamut_best(&samples, channels, budget.side, false); + let cleaned = gamut_best(&samples, channels, budget.side, true); + assert!( + cleaned.len() <= plain.len(), + "{}: cleanup cost {} bytes ({} -> {}); the race in `cleaned_or_plain` should have \ + kept the uncleaned encoding", + budget.name, + cleaned.len() - plain.len(), + plain.len(), + cleaned.len(), + ); + } +} From 448c3f678c518569c1183cae39603d428775e175 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 20:07:13 -0400 Subject: [PATCH 32/54] docs(png): say which efficiency tables are gated and which only report "Everything here is produced by `cargo bench` and gated by `tests/size_contract.rs`" was true of neither half. The size table is now gated in full -- every row including `tiny_rgb8` and both `+clean` columns -- while the throughput and per-heuristic tables are reported only, because a timing assertion cannot fail a build without making it flaky, which is why CI runs the benches for compile rot alone (#437). Saying so is the point: a reader deciding whether a number is load-bearing should not have to open the test. Axis 5 was stale in both directions. Cleanup is worth 40.1% on the sprite row, not the 30% recorded before palette ordering landed, and it now applies to every alpha-carrying layout at 8 and 16 bits. It is also raced rather than assumed: on `palette64_rgba8` cleaning measures -2.3% at 32x32, +10.7% at 128x128 and -5.2% at 256x256, so the axis is only "done" because `cleaned_or_plain` keeps whichever encoding is smaller. The `choose_min_sum_abs` throughput row is dropped with the function. Its 4.5x measured a per-scanline 9 KiB `Scratch` allocation the encoder never performs, so the figure described the benchmark rather than the codec. The size and per-heuristic tables are re-measured at this revision and unchanged, which is the result worth recording for the entropy score's restatement: it is ranking-equivalent on every corpus row. The bench gains `docs/benchmarking.md`'s house phrase, naming the axes it deliberately does not measure. --- crates/gamut-png/STATUS.md | 13 +++++++++---- crates/gamut-png/benches/encode.rs | 11 +++++++++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/crates/gamut-png/STATUS.md b/crates/gamut-png/STATUS.md index da174e4a..f6e366dd 100644 --- a/crates/gamut-png/STATUS.md +++ b/crates/gamut-png/STATUS.md @@ -57,8 +57,13 @@ opts into narrowing. That is distinct from the encoder's *lossless* auto-reduce Correctness was settled long before efficiency was measured. This section is the measured state: what the encoder achieves, what it costs, and — per axis — what it does not do yet. -Everything here is produced by `cargo bench -p gamut-png` and gated by -`tests/size_contract.rs`. One machine, so **read the ratios, not the absolute times**. +Everything here is produced by `cargo bench -p gamut-png`. What is *gated* is narrower, and +worth being precise about: `tests/size_contract.rs` asserts the size table -- every row including +`tiny_rgb8` and both `+clean` columns -- as a ratio against libpng-9 at 128×128, and pins +`with_transparent_cleanup` never costing bytes on any row. The throughput and per-heuristic tables +below are **reported, not gated**: timings cannot fail a build without making it flaky, which is +why CI runs the benches for compile-rot only ([#437]). One machine, so **read the ratios, not the +absolute times**. ### Output size vs libpng at zlib level 9 @@ -114,7 +119,6 @@ selectable: eight images is a corpus, not a proof. | `filter_image` / None | 497.9 MB/s | 16.26 GB/s | 33× | | `filter_image` / `Fixed(Paeth)` | 277.1 MB/s | 1.202 GB/s | 4.3× | | `filter_image` / `MinSumAbs` | 46.7 MB/s | 265.8 MB/s | 5.7× | -| `choose_min_sum_abs` | 68.0 MB/s | 308.4 MB/s | 4.5× | All safe Rust: `crc32fast` keeps its `unsafe` to itself, and the filter gains are structural (hoisting a loop-invariant branch, equal-length subslices, one `match` per row instead of per @@ -128,7 +132,7 @@ byte) plus removing a sixth redundant filter pass per scanline. | 2 | DEFLATE quality | **good, ~2% behind zopfli**, and honestly documented in `gamut-deflate`. Two contained wins remain: an 8-byte-at-a-time match compare, and `parse_dp`'s single-distance relaxation. [#478], [#479] | | 3 | Smallest lawful representation | **done** — grey, alpha-drop, ≤256 palette, 16→8, sub-byte, and a `tRNS` colour key for grey/truecolour. The key is worth ~7–9% on a contiguous transparent region, *not* the 25% the raw-byte arithmetic suggests: the alpha plane it removes is usually the most compressible plane in the image. | | 4 | Palette optimization | **partial** — trailing-opaque `tRNS` trim, plus ordering: transparent entries first (so that trim cuts as far as §11.3.2.1 allows) then by luma. Worth −14.7% on the sprite row against +1.5% on `palette64`. Modified-Zeng ordering and caller-supplied palette cleanup remain. [#482] | -| 5 | Cleaning invisible data | **done** — `with_transparent_cleanup`, opt-in. Worth 30% on the sprite row, and it is what makes a colour key reachable at all on a source whose invisible pixels carry different unseen colours. | +| 5 | Cleaning invisible data | **done** — `with_transparent_cleanup`, opt-in, on every alpha-carrying layout at 8 and 16 bits. Worth **40.1%** on the sprite row, and it is what makes a colour key reachable at all on a source whose invisible pixels carry different unseen colours. It is a *transform*, not a reduction, so it is **raced** rather than assumed: on `palette64_rgba8` cleaning measured −2.3% at 32×32, **+10.7% at 128×128** and −5.2% at 256×256, because zeroing invisible pixels that carry structure destroys bytes DEFLATE was compressing. `cleaned_or_plain` encodes both and keeps the smaller, so the knob can never cost bytes. | | 6 | Metadata hygiene | **no policy** — the encoder emits exactly what the caller set, and `gamut convert` drops metadata on the PNG path. [#483] | | 7 | Interlacing | **correctly none.** Adam7 costs 5–20%; out of scope by declaration. | | 8 | Effort / speed / determinism | Output is byte-reproducible (no time, no randomness, and the one `HashMap` is never iterated). Three independent knobs, no composed dial. No parallelism. [#484] | @@ -154,6 +158,7 @@ way `FilterStrategy::BruteForce` already resolves filters — no tuned constant, either candidate alone. Only palette reductions pay for the second encode; greyscale, alpha-drop and 16→8 demotion add no chunks, so for them the raw comparison is sound. +[#437]: https://github.com/visualcommons/gamut/issues/437 [#478]: https://github.com/visualcommons/gamut/issues/478 [#479]: https://github.com/visualcommons/gamut/issues/479 [#480]: https://github.com/visualcommons/gamut/issues/480 diff --git a/crates/gamut-png/benches/encode.rs b/crates/gamut-png/benches/encode.rs index d19351c1..a66f5eac 100644 --- a/crates/gamut-png/benches/encode.rs +++ b/crates/gamut-png/benches/encode.rs @@ -13,6 +13,17 @@ //! Counters report bytes of *source* pixels per second, so figures are comparable with the other //! codec suites. Run with `cargo bench -p gamut-png` (or `mise run bench`); add //! `--features test-support` for the per-stage rows. +//! +//! Intentionally tight: this measures **encoding**, on a generated 8-bit corpus, and nothing else. +//! There is no decode axis -- `PngDecoder`'s cost is a separate question against a separate +//! oracle, and folding the two into one aggregate would let a decode win mask an encode +//! regression. There is no ancillary-chunk axis: a metadata chunk costs its own payload plus +//! twelve bytes of framing, and the one piece of real work in the compressed ones (`iCCP`, +//! `zTXt`) is a `gamut-deflate` call that `gamut-deflate`'s own suite already measures -- none of +//! it is decided by the encoder's pixel path. There is no interlace axis because there is nothing +//! to measure: `ihdr::write` always emits interlace method 0, and Adam7 is a decode-side feature +//! here. What is left -- compression level, filter strategy, auto-reduce, and the corpus itself -- +//! are the four axes the encoder actually chooses between. use divan::counter::BytesCount; use divan::{Bencher, black_box}; From c83114735866ee8e8dfaf395aff19571d5366564 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 20:07:45 -0400 Subject: [PATCH 33/54] style(png): drop a stray blank line in the size contract --- crates/gamut-png/tests/size_contract.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/gamut-png/tests/size_contract.rs b/crates/gamut-png/tests/size_contract.rs index ede488f5..243a4a85 100644 --- a/crates/gamut-png/tests/size_contract.rs +++ b/crates/gamut-png/tests/size_contract.rs @@ -346,7 +346,6 @@ fn encoded_size_is_deterministic() { } } - #[test] fn cleanup_never_costs_bytes_on_any_corpus_row() { // The gate on `with_transparent_cleanup`'s central claim. It is only true because the encoder From e2c38fba1b6d9bcae7c1853cfb3959049dcc2334 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 2 Sep 2026 00:45:46 -0400 Subject: [PATCH 34/54] test(png): pin the cleanup tie-break and the entropy weighting The two survivors CI reported against the last push, both in code this series added. `cleaned_or_plain` inlined its comparison, so nothing pinned the tie its doc promises. `write_reduced_or_native` already had this problem and already solved it: `prefers_native` exists because two encodings of the same image cannot be made to land on exactly equal lengths by any fixture, so the tie is only assertable at the boundary. `prefers_plain` is its twin, and it keeps the cleaned encoding on a tie -- less unseen data for the same bytes. The entropy weighting needed a fixture no existing row provided. Replacing `c * log2(n/c)` with `c + log2(n/c)` leaves a score that mostly counts distinct symbols, and every vector in the suite happens to rank the same way under both. The new pair inverts: sixteen bytes split evenly between two symbols carry a full bit each, while fourteen of one symbol plus two singletons carry less information despite having *more* distinct symbols. Weighted, the concentrated row scores lower; unweighted it scores higher, because it has three log terms against two. Both verified by hand-applying the exact mutation and running the package suite. No `.cargo/mutants.toml` exclusions. --- crates/gamut-png/src/encoder.rs | 19 ++++++++++++++++++- crates/gamut-png/src/filter.rs | 25 +++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/crates/gamut-png/src/encoder.rs b/crates/gamut-png/src/encoder.rs index 5bbf4dd2..9757ca41 100644 --- a/crates/gamut-png/src/encoder.rs +++ b/crates/gamut-png/src/encoder.rs @@ -449,7 +449,7 @@ impl PngEncoder { let mut plain_encoding = Vec::new(); plain(&mut plain_encoding)?; - let winner = if plain_encoding.len() < cleaned_encoding.len() { + let winner = if prefers_plain(plain_encoding.len(), cleaned_encoding.len()) { plain_encoding } else { cleaned_encoding @@ -722,6 +722,16 @@ impl PngEncoder { } } +/// Whether the uncleaned encoding beats the cleaned one, for [`PngEncoder::cleaned_or_plain`]. +/// +/// **A tie keeps the cleaned encoding**, which carries less unseen data for the same bytes. Split +/// out for the same reason as [`prefers_native`]: engineering two encodings of the same image to +/// land on exactly equal lengths is not something a fixture can do reliably, so the tie is only +/// assertable here. +fn prefers_plain(plain_len: usize, cleaned_len: usize) -> bool { + plain_len < cleaned_len +} + /// Whether the unreduced encoding beats the palette one, for [`PngEncoder::write_reduced_or_native`]. /// /// **A tie keeps the palette**, which decodes with less work for the same bytes. Split out because @@ -1059,6 +1069,13 @@ mod tests { assert!(!prefers_native(10, 10), "a tie keeps the palette"); } + #[test] + fn a_tie_between_cleaned_and_plain_keeps_the_cleaned_encoding() { + assert!(prefers_plain(10, 11), "smaller plain wins"); + assert!(!prefers_plain(11, 10), "smaller cleaned wins"); + assert!(!prefers_plain(10, 10), "a tie keeps the cleaned encoding"); + } + #[test] fn brute_force_keeps_the_first_strategy_on_a_tie() { // A 1x1 image compresses to the same length under every strategy, so the tie-break is what diff --git a/crates/gamut-png/src/filter.rs b/crates/gamut-png/src/filter.rs index 97ad26c6..ce0b5739 100644 --- a/crates/gamut-png/src/filter.rs +++ b/crates/gamut-png/src/filter.rs @@ -535,6 +535,31 @@ mod tests { ); } + #[test] + fn the_entropy_score_weights_each_symbol_by_how_often_it_occurs() { + // Entropy is `sum c*log2(n/c)`, not `sum log2(n/c)`: each symbol's surprise is weighted by + // how much of the row it accounts for. Drop the weighting and the score degenerates into + // something that mostly counts distinct symbols, which ranks these two rows the other way + // round. + // + // `balanced` is two symbols split evenly -- the worst case for a two-symbol row, a full + // bit per byte. `concentrated` has *more* distinct symbols but spends 14 of its 16 bytes + // on one of them, so it carries less information and must score lower. Unweighted it + // scores higher, because it has three log terms against two. + let mut aux = Scratch::new(); + let mut balanced = vec![0u8; 8]; + balanced.extend(std::iter::repeat_n(1u8, 8)); + let mut concentrated = vec![0u8; 14]; + concentrated.extend_from_slice(&[1, 2]); + + let balanced = score(Score::Entropy, &balanced, &mut aux); + let concentrated = score(Score::Entropy, &concentrated, &mut aux); + assert!( + concentrated < balanced, + "concentrated {concentrated} should score below balanced {balanced}" + ); + } + #[test] fn the_entropy_scale_separates_rows_closer_than_one_bit() { // The scale is what makes the score integer-exact: these two rows carry 8.000 and 8.490 From 7593fe596b82ebad696f68123fcf560076b2956e Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 2 Sep 2026 17:38:35 -0400 Subject: [PATCH 35/54] feat(png): bound the deconstruct walk and name what it actually read The walk took two attacker-chosen quantities on trust and conflated two different verdicts. `DeconstructLimits` makes both ceilings the caller's. `max_image_bytes` was hard-coded to the decoder's default, so "a report never allocates more than a decode would" held only against a default-configured decoder; it is now a parameter, with `deconstruct_with_limits` beside `deconstruct` and builder methods matching `PngDecoder::with_max_image_bytes`. `max_chunks` is new: a chunk costs 12 bytes of input and buys a `Segment`, plus a `ChunkStats` and an index entry for a type not seen before, so an unbounded chunk count is unbounded heap at roughly an order of magnitude over the file size -- and the chunk type is four unvalidated bytes, so the distinct-type count is chosen by the input too. Every other attacker-driven quantity in this crate already has a documented cap; this one had none. `is_verified` separates "this file was read" from `is_intact`'s "nothing is known against this file". They are not the same claim: a file whose IDAT was never inflated satisfies `is_intact` vacuously, and a corrupt zlib payload under a valid CRC is damage only the scan can see. `FilterScan::is_counted` answers the narrow question both rest on. `is_intact` keeps its meaning, which is the one a report wants; a gate wants the other. `pass_stats` now checks its running total the way `adam7::expected_stream_len` does. Bailing out only per pass let an interlaced header whose seven passes each fit `usize` but whose sum does not report all seven passes against a `filtered_len` saturated to 0 -- and `idat_ratio` then printed `0.0%` as though it were a measurement. --- crates/gamut-png/src/deconstruct.rs | 140 ++++++++++++++++++++++++++-- crates/gamut-png/src/lib.rs | 4 +- 2 files changed, 136 insertions(+), 8 deletions(-) diff --git a/crates/gamut-png/src/deconstruct.rs b/crates/gamut-png/src/deconstruct.rs index fb249d97..e70576c6 100644 --- a/crates/gamut-png/src/deconstruct.rs +++ b/crates/gamut-png/src/deconstruct.rs @@ -182,6 +182,17 @@ impl FilterScan { } } + /// Whether the scan actually ran, so the counts describe bytes this reader read. + /// + /// The complement of [`is_damage`](Self::is_damage) only for a scan that ran: a skip is + /// either damage or a budget refusal, and **neither is a verification**. A caller grading a + /// file — [`PngReport::is_verified`], an archival gate — asks this; a caller asking whether + /// anything is known to be *wrong* asks `is_damage`. + #[must_use] + pub fn is_counted(self) -> bool { + matches!(self, Self::Counted(_)) + } + /// Whether the missing counts mean the *file* is damaged — see /// [`SkippedFilterScan::is_damage`]. A scan that ran is never damage. #[must_use] @@ -305,6 +316,20 @@ impl PngReport { && self.chunk(b"IEND").is_some() } + /// Whether this file is intact **and every byte of it was actually read**: `is_intact()` plus + /// [`FilterScan::is_counted`]. + /// + /// The distinction [`is_intact`](Self::is_intact) deliberately does not make. `is_intact` is + /// "nothing is known to be wrong", which a file whose IDAT was never inflated satisfies + /// vacuously — and a corrupt zlib payload under a valid CRC is damage *only* the scan can + /// see, so for an over-budget file `is_intact` is a statement about this reader's budget + /// rather than about the bytes. A gate that must not pass an unread file asks this instead; + /// a caller reporting what is known against a file keeps asking `is_intact`. + #[must_use] + pub fn is_verified(&self) -> bool { + self.is_intact() && self.filters.is_counted() + } + /// **Stored bits per image pixel** — the space-efficiency figure of merit: the whole file, /// framing and metadata included, over `width × height`. Distinct from the *uncompressed* /// rate, which is `header.color_type.channels() × header.bit_depth`. @@ -440,13 +465,93 @@ impl ChunkTally { /// Works on any PNG, whichever encoder produced it, which is what makes the figures comparable /// across encoders (issue #224). /// +/// Walks under [`DeconstructLimits::default()`]. Use +/// [`deconstruct_with_limits`] to match a decoder you configured yourself. +/// /// # Errors /// -/// Returns [`Error::InvalidInput`] only when there is no header to report on: a bad signature, no -/// first chunk, a first chunk that is not IHDR, or an IHDR whose payload is invalid. Everything -/// else is **reported, not errored** — unknown ancillary *and critical* chunks, CRC mismatches, a -/// missing IEND, trailing bytes after IEND, a truncated tail, and a corrupt IDAT stream. +/// Returns [`Error::InvalidInput`] when there is no header to report on — a bad signature, no +/// first chunk, a first chunk that is not IHDR, or an IHDR whose payload is invalid — or when the +/// input carries more chunks than [`DeconstructLimits::max_chunks`] allows. Everything else is +/// **reported, not errored** — unknown ancillary *and critical* chunks, CRC mismatches, a missing +/// IEND, trailing bytes after IEND, a truncated tail, and a corrupt IDAT stream. pub fn deconstruct(png: &[u8]) -> Result { + deconstruct_with_limits(png, DeconstructLimits::default()) +} + +/// The ceilings a [`deconstruct`] walk observes on attacker-chosen quantities. +/// +/// Every field is a quantity the *input* chooses, which is why each has a ceiling: a report is +/// routinely run over files from anywhere (`gamut inspect` is pointed at whatever is on disk), and +/// the crate's decoder already caps the same quantities for the same reason. +/// +/// Non-exhaustive: ceilings may be added without a breaking change. Build from +/// [`default()`](Self::default) and adjust the fields you care about. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub struct DeconstructLimits { + /// The largest decoded image, in bytes, whose IDAT stream is worth inflating to count + /// filters. Above it the scan is skipped as [`SkippedFilterScan::OverBudget`] and every other + /// figure is still reported, because everything else is derived from framing and IHDR. + /// + /// This is the quantity [`crate::PngDecoder::with_max_image_bytes`] budgets, and matching the + /// two is the point: a report is only "what a decode would have allocated" against a decoder + /// configured the same way. The default matches the decoder's default. + pub max_image_bytes: usize, + /// The largest number of chunks the walk will materialize into segments and per-type stats. + /// + /// A chunk costs 12 bytes of input and buys a `Segment` plus, for a type not seen before, a + /// `ChunkStats` and an index entry — so an input of unbounded chunk count is an input of + /// unbounded heap, at roughly an order of magnitude over the file size. The chunk *type* is + /// four unvalidated bytes, so the distinct-type count is attacker-chosen too. + /// + /// The default admits any plausible real file — a PNG at the ceiling is at least 12 MiB of + /// pure chunk framing — while bounding a crafted one. + pub max_chunks: usize, +} + +/// The chunk-count ceiling a default [`deconstruct`] walk observes. +/// +/// A PNG reaching it carries at least 12 MiB of chunk framing alone, which no real file does and a +/// crafted one reaches cheaply. +pub const DEFAULT_MAX_CHUNKS: usize = 1 << 20; + +impl Default for DeconstructLimits { + fn default() -> Self { + Self { + max_image_bytes: DEFAULT_MAX_IMAGE_BYTES, + max_chunks: DEFAULT_MAX_CHUNKS, + } + } +} + +impl DeconstructLimits { + /// Sets [`max_image_bytes`](Self::max_image_bytes). + /// + /// Builder methods rather than a struct literal, matching + /// [`PngDecoder::with_max_image_bytes`](crate::PngDecoder::with_max_image_bytes) — and + /// necessary as well as symmetrical, since a non-exhaustive struct cannot be built by literal + /// outside this crate at all. + #[must_use] + pub fn with_max_image_bytes(mut self, bytes: usize) -> Self { + self.max_image_bytes = bytes; + self + } + + /// Sets [`max_chunks`](Self::max_chunks). + #[must_use] + pub fn with_max_chunks(mut self, chunks: usize) -> Self { + self.max_chunks = chunks; + self + } +} + +/// [`deconstruct`], under caller-chosen [`DeconstructLimits`]. +/// +/// # Errors +/// +/// As [`deconstruct`], against `limits` rather than the defaults. +pub fn deconstruct_with_limits(png: &[u8], limits: DeconstructLimits) -> Result { let mut reader = ChunkReader::new(png)?; let mut segments = vec![Segment { range: 0..SIGNATURE.len(), @@ -496,6 +601,12 @@ pub fn deconstruct(png: &[u8]) -> Result { } let is_iend = &chunk.chunk_type == b"IEND"; push(&mut segments, &mut tally, &chunk); + if segments.len() > limits.max_chunks { + return Err(Error::invalid_input( + env!("CARGO_PKG_NAME"), + "PNG: more chunks than the walk's ceiling admits", + )); + } if is_iend { saw_iend = true; break; @@ -531,7 +642,13 @@ pub fn deconstruct(png: &[u8]) -> Result { let passes = pass_stats(&native); let filtered_len = adam7::expected_stream_len(&native).unwrap_or(0); - let filters = scan_filters(&native, &idat, filtered_len, &passes); + let filters = scan_filters( + &native, + &idat, + filtered_len, + &passes, + limits.max_image_bytes, + ); Ok(PngReport { file_len: png.len(), @@ -550,6 +667,7 @@ pub fn deconstruct(png: &[u8]) -> Result { /// against it rather than merely asserted. fn pass_stats(header: &ihdr::Ihdr) -> Vec { let mut out = Vec::new(); + let mut total = 0usize; for (index, pass) in adam7::passes_for(header.interlaced).iter().enumerate() { let (width, height) = adam7::pass_dimensions(pass, header.width, header.height); if width == 0 || height == 0 { @@ -567,6 +685,15 @@ fn pass_stats(header: &ihdr::Ihdr) -> Vec { else { return Vec::new(); }; + // `adam7::expected_stream_len` fails on the seven-pass *sum* as well as on each pass, so + // this has to fail with it. Without the running check, a header whose passes each fit but + // whose total overflows leaves `filtered_len` saturated to 0 while `passes` still + // describes all seven -- a self-inconsistent report, and a `0.0%` ratio that reads as a + // measurement rather than as an overflow. + let Some(running) = total.checked_add(filtered_len) else { + return Vec::new(); + }; + total = running; out.push(PassStats { index: index as u8, width, @@ -613,8 +740,9 @@ fn scan_filters( idat: &[u8], filtered_len: usize, passes: &[PassStats], + max_image_bytes: usize, ) -> FilterScan { - if !fits_decode_budget(header, DEFAULT_MAX_IMAGE_BYTES) { + if !fits_decode_budget(header, max_image_bytes) { return FilterScan::Skipped(SkippedFilterScan::OverBudget); } let Ok(stream) = inflate::inflate_zlib(idat, filtered_len) else { diff --git a/crates/gamut-png/src/lib.rs b/crates/gamut-png/src/lib.rs index 473ef30f..b2419d53 100644 --- a/crates/gamut-png/src/lib.rs +++ b/crates/gamut-png/src/lib.rs @@ -78,8 +78,8 @@ pub use decoded::{ }; pub use decoder::{PngDecoder, TransparencyKey, metadata}; pub use deconstruct::{ - ChunkStats, FilterHistogram, FilterScan, PassStats, PngReport, Segment, SegmentKind, - SkippedFilterScan, deconstruct, + ChunkStats, DEFAULT_MAX_CHUNKS, DeconstructLimits, FilterHistogram, FilterScan, PassStats, + PngReport, Segment, SegmentKind, SkippedFilterScan, deconstruct, deconstruct_with_limits, }; pub use encoder::PngEncoder; pub use filter::{FilterStrategy, FilterType}; From a0bde8eb560ea37ba70fb53596a62a8faf4e4951 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 2 Sep 2026 17:38:51 -0400 Subject: [PATCH 36/54] test(png): pin the walk's ceilings, its saturation and the unread verdict Four cases the suite could not see. `UndefinedFilterCode` was the only skip reason with no fixture: the variant appeared in an `is_damage` assertion and a discriminant pin, but nothing drove `scan_filters` into it. Delete the `FilterType::from_code` guard and a hostile file's undefined code is counted as `None` under a bogus histogram, with every other assertion still passing. `is_verified` needs the case that separates it from `is_intact` -- an over-budget file, where nothing is known to be wrong and nothing was read. The chunk ceiling is asserted from both sides, so the cap cannot degenerate into a refusal to measure. The interlaced overflow twin covers where the two checks disagree: seven passes that each fit `usize` while their sum does not. --- crates/gamut-png/tests/accounting.rs | 121 ++++++++++++++++++++++++++- 1 file changed, 119 insertions(+), 2 deletions(-) diff --git a/crates/gamut-png/tests/accounting.rs b/crates/gamut-png/tests/accounting.rs index 8cdd666e..5afd2f8a 100644 --- a/crates/gamut-png/tests/accounting.rs +++ b/crates/gamut-png/tests/accounting.rs @@ -13,8 +13,8 @@ use std::time::Instant; use gamut_core::{Dimensions, EncodeImage, ImageRef, Rgb8, Rgba8}; use gamut_png::{ - ChunkStats, FilterScan, FilterStrategy, FilterType, PngEncoder, Segment, SegmentKind, - SkippedFilterScan, deconstruct, + ChunkStats, DeconstructLimits, FilterScan, FilterStrategy, FilterType, PngEncoder, Segment, + SegmentKind, SkippedFilterScan, deconstruct, deconstruct_with_limits, }; /// Folds over the segments asserting: non-empty, first starts at 0, each end chains to the next @@ -492,6 +492,91 @@ fn a_corrupt_zlib_stream_with_a_valid_crc_yields_no_histogram() { assert!(report.filtered_len > 0); } +#[test] +fn an_undefined_filter_code_is_named_and_is_damage() { + // The fourth skip reason, and the only one with no fixture of its own: a stream that inflates + // to exactly the right length but whose scanline carries a filter code PNG SS9.1 does not + // define. Without this the `FilterType::from_code` guard can be deleted -- counting an + // undefined code as `None` and reporting a bogus histogram for a hostile file -- and every + // other assertion in the suite still passes. + let filtered = [9u8, 0, 0, 0, 0, 0, 0]; // 2x1 RGB8: one row, 6 bytes, filter code 9. + let png = common::png_from_chunks(&[ + common::chunk(b"IHDR", &common::ihdr_payload(2, 1, 8, 2, 0)), + common::chunk(b"IDAT", &common::zlib(&filtered)), + common::chunk(b"IEND", &[]), + ]); + let report = deconstruct(&png).expect("an undefined filter code is reported, not an error"); + + assert_covers(&report.segments, png.len()); + assert_eq!( + report.filtered_len, 7, + "one row of 6 bytes plus its filter byte" + ); + assert_eq!( + report.filters, + FilterScan::Skipped(SkippedFilterScan::UndefinedFilterCode), + "the scan names the undefined code rather than any other reason" + ); + assert!( + report.filters.is_damage(), + "an undefined filter code is a statement about the bytes" + ); + assert!(!report.is_intact()); +} + +#[test] +fn an_unread_file_is_intact_but_not_verified() { + // The distinction `is_verified` exists to make. Nothing is known to be wrong with an + // over-budget file, so `is_intact` is true -- but its IDAT was never inflated, so no claim + // about the compressed data has been checked and `is_verified` is false. Collapsing the two + // is what let an archival gate pass a file it never read. + let png = common::png_with_huge_ihdr(); + let report = deconstruct(&png).expect("deconstruct"); + + assert_eq!( + report.filters, + FilterScan::Skipped(SkippedFilterScan::OverBudget) + ); + assert!( + !report.filters.is_damage(), + "a budget refusal is not damage" + ); + assert!( + !report.filters.is_counted(), + "and it is not a reading either" + ); + assert!(report.is_intact(), "nothing is known against this file"); + assert!( + !report.is_verified(), + "but nothing about its compressed data was checked" + ); +} + +#[test] +fn a_file_past_the_chunk_ceiling_is_refused() { + // The chunk count is chosen by the input -- a chunk costs 12 bytes and buys a segment -- so + // the walk caps it. Below the ceiling the same file reports normally, which is what keeps the + // cap from being a refusal to measure. + let mut chunks = vec![common::chunk(b"IHDR", &common::ihdr_payload(1, 1, 8, 0, 0))]; + for _ in 0..8 { + chunks.push(common::chunk(b"crUD", &[])); + } + chunks.push(common::chunk(b"IEND", &[])); + let png = common::png_from_chunks(&chunks); + + let generous = DeconstructLimits::default().with_max_chunks(100); + let report = deconstruct_with_limits(&png, generous).expect("under the ceiling"); + assert_eq!(report.segments.len(), 11, "signature plus ten chunks"); + + let stingy = DeconstructLimits::default().with_max_chunks(4); + let err = deconstruct_with_limits(&png, stingy) + .expect_err("past the ceiling the walk refuses rather than allocating"); + assert!( + err.to_string().contains("more chunks"), + "the error names the ceiling it hit, got: {err}" + ); +} + #[test] fn an_over_budget_image_reports_everything_but_the_histogram() { // A hand-built IHDR claiming 2^30 x 2^30 with a tiny IDAT: the image it implies is far past @@ -588,6 +673,38 @@ fn a_header_whose_stream_overflows_reports_a_zero_ratio_rather_than_dividing_by_ ); } +/// The interlaced twin of the case above, which is where the two overflow checks can disagree. +/// +/// Adam7 splits the image into seven smaller passes, so a header can be unrepresentable overall +/// while every individual pass fits `usize`. `adam7::expected_stream_len` fails on the seven-pass +/// *sum*, so `filtered_len` saturates to 0; `pass_stats` has to fail on the same sum or the report +/// contradicts itself -- seven passes described, and a `filtered_len` of 0 that `idat_ratio` then +/// reports as `0.0%` as though it were a measurement. +#[test] +fn an_interlaced_header_whose_passes_fit_but_whose_sum_does_not_reports_no_geometry() { + let png = common::png_from_chunks(&[ + common::chunk( + b"IHDR", + &common::ihdr_payload(0x7FFF_FFFF, 0x7FFF_FFFF, 16, 6, 1), + ), + common::chunk(b"IDAT", &common::zlib(&[0u8; 8])), + common::chunk(b"IEND", &[]), + ]); + let report = deconstruct(&png).expect("an unrepresentable stream is reported, not an error"); + + assert_covers(&report.segments, png.len()); + assert_eq!( + report.filtered_len, 0, + "the seven-pass sum is not representable" + ); + assert!( + report.passes.is_empty(), + "and the per-pass geometry must saturate with it, not describe seven passes \ + against a zero total" + ); + assert_eq!(report.idat_ratio(), 0.0, "no division by zero"); +} + #[test] fn a_file_with_no_header_to_report_on_is_an_error() { assert!(deconstruct(&[]).is_err(), "empty input"); From cd70f785dd94e3bbfc1e0f43b4e23b140f6165fd Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 2 Sep 2026 17:38:51 -0400 Subject: [PATCH 37/54] fix(cli): gate inspect on what it read, and bound the lists it prints `gamut inspect` sells itself as an archival CI gate and exited 0 on any file whose IDAT it never inflated. At the decoder's 64 MiB budget that was every PNG past 4096x4096 RGBA8 -- an ordinary photograph -- reported `intact: yes` whatever the compressed stream contained. Chunk CRCs do not cover it: a corrupt-but-CRC-valid IDAT is exactly the damage only the scan can see. Two changes, because there were two faults. The walk's budget here is now a gigabyte rather than the decoder's default: a decoder's budget guards a decode against hostile input, while reading the file is this command's whole job, and past any real image is the right place for that line. And the gate is `is_verified`, so a file that still could not be read exits non-zero saying it was not verified, distinctly from a damaged one. `intact:` is still printed and still true -- nothing is held against such a file -- but it is no longer mistaken for a verification. Measured on a 4100x4100 RGBA8 image, past the old budget: sound, it now counts all 4100 scanlines and exits 0; with its IDAT corrupted under a valid chunk CRC, it now exits 1. Both exited 0 before. The per-chunk-type table also bypassed `MAX_LIST`, so 400k distinct types in a 4.8 MB file printed 23.6 MB of stdout, and the findings list materialized one `String` per damaged chunk before truncating at print. Both are now built under the bound they are printed under, with the true total still reported. --- crates/gamut-cli/src/commands/inspect.rs | 127 ++++++++++++++++------- 1 file changed, 92 insertions(+), 35 deletions(-) diff --git a/crates/gamut-cli/src/commands/inspect.rs b/crates/gamut-cli/src/commands/inspect.rs index b53b6120..dbb0bc7a 100644 --- a/crates/gamut-cli/src/commands/inspect.rs +++ b/crates/gamut-cli/src/commands/inspect.rs @@ -12,16 +12,24 @@ //! //! - **TIFF / DNG** — `is_fully_accounted()`: every byte classified, *and* no unknown field //! type, no unknown tag, and no anomaly. -//! - **PNG** — `is_intact()`: every byte classified, *and* every chunk CRC valid, IEND present, -//! no trailing bytes after it, no truncated tail, and nothing the filter scan found damaging. +//! - **PNG** — `is_verified()`: `is_intact()` (every byte classified, every chunk CRC valid, IEND +//! present, no trailing bytes after it, no truncated tail, nothing the filter scan found +//! damaging) *and* the filter scan actually ran. //! //! PNG's `is_fully_classified()` is **not** the gate, though it is printed: it is true by //! construction for every file `deconstruct` accepts (a truncated tail and a trailer each get a //! segment of their own, so the tiling still covers the file), and gating on it would exit 0 on a //! truncated PNG. It exists so that a walk *bug* makes the predicate false. //! -//! A PNG whose filter scan was skipped only because the image is larger than this reader's byte -//! budget is not a finding: nothing is known to be wrong with it. +//! `is_intact()` is **not** the gate either, and the difference is the reason `is_verified` exists. +//! A PNG whose filter scan was skipped for budget is not *damaged* — nothing is known to be wrong +//! with it — so it is not a finding, and `intact: yes` is printed truthfully. But a corrupt zlib +//! payload under a valid CRC is damage only the scan can see, so an unread file is one this +//! command cannot vouch for, and exiting 0 on it would report this reader's budget as a property +//! of the file. Such a file exits non-zero saying it was not verified, distinctly from a damaged +//! one. To keep that rare, the walk's budget here is a gigabyte rather than the decoder's 64 MiB, +//! which is past any real image — at the decoder's budget every PNG over 4096x4096 RGBA8 would go +//! unread. //! //! For PNG the same walk answers a second question: **where did the bytes go?** The report carries //! the per-chunk-type breakdown, the compressed IDAT total against the filtered stream it inflates @@ -366,15 +374,23 @@ fn print_ranges(label: &str, ranges: &[(u64, u64)]) { /// Prints a pre-formatted line list under `label`, truncating past [`MAX_LIST`]. fn print_lines(label: &str, lines: &[String]) { - if lines.is_empty() { + print_lines_of(label, lines, lines.len()); +} + +/// [`print_lines`], where `lines` is already truncated and `total` is how many there really are. +/// +/// Splitting the count from the list is what lets a caller whose list length is chosen by the +/// input build only the lines it will print while still reporting the true total. +fn print_lines_of(label: &str, lines: &[String], total: usize) { + if total == 0 { return; } - println!(" {label}: {}", lines.len()); + println!(" {label}: {total}"); for line in lines.iter().take(MAX_LIST) { println!(" - {line}"); } - if lines.len() > MAX_LIST { - println!(" … and {} more", lines.len() - MAX_LIST); + if total > lines.len() { + println!(" … and {} more", total - lines.len()); } } @@ -383,7 +399,13 @@ fn print_lines(label: &str, lines: &[String]) { fn inspect_png(path: &std::path::Path, data: &[u8]) -> Result<(), CliError> { use gamut::png::{FilterScan, FilterType, SegmentKind}; - let report = gamut::png::deconstruct(data)?; + // Inspection budgets differently from decoding. `gamut::png::deconstruct`'s default matches + // the *decoder*'s, which guards a decode against hostile input; but a file this command + // declines to inflate is a file it cannot verify, and at the decoder's 64 MiB that is every + // PNG past 4096x4096 RGBA8 -- an ordinary photograph. Reading it is the whole job, so the + // ceiling is raised to a gigabyte: past any real image, short of unbounded. + let limits = gamut::png::DeconstructLimits::default().with_max_image_bytes(1 << 30); + let report = gamut::png::deconstruct_with_limits(data, limits)?; let header = report.header; println!("{}: PNG", path.display()); @@ -416,8 +438,10 @@ fn inspect_png(path: &std::path::Path, data: &[u8]) -> Result<(), CliError> { report.framing_bytes() ); - println!(" chunks:"); - for stats in &report.chunks { + // Truncated like every other list here: a chunk type is four unvalidated bytes, so the number + // of distinct types is chosen by the input, not by the image. + println!(" chunks: {}", report.chunks.len()); + for stats in report.chunks.iter().take(MAX_LIST) { println!( " {} x{:<3} {:>9} payload + {:>4} framing{}", String::from_utf8_lossy(&stats.chunk_type), @@ -431,6 +455,9 @@ fn inspect_png(path: &std::path::Path, data: &[u8]) -> Result<(), CliError> { } ); } + if report.chunks.len() > MAX_LIST { + println!(" … and {} more", report.chunks.len() - MAX_LIST); + } match report.filters { FilterScan::Counted(h) => { @@ -463,55 +490,85 @@ fn inspect_png(path: &std::path::Path, data: &[u8]) -> Result<(), CliError> { } } + // One damaged chunk yields one `String`, and the chunk count is chosen by the input, so the + // list is built under the same bound it is printed under: the total is counted separately and + // only the lines that will be shown are ever materialized. + let is_damaged_segment = |seg: &gamut::png::Segment| { + matches!( + seg.kind, + SegmentKind::Chunk { crc_ok: false, .. } + | SegmentKind::Truncated + | SegmentKind::Trailer + ) + }; + let mut findings = report + .segments + .iter() + .filter(|seg| is_damaged_segment(seg)) + .count(); let mut damaged: Vec = report .segments .iter() - .filter_map(|seg| match seg.kind { - SegmentKind::Chunk { - chunk_type, - crc_ok: false, - .. - } => Some(format!( + .filter(|seg| is_damaged_segment(seg)) + .take(MAX_LIST) + .map(|seg| match seg.kind { + SegmentKind::Chunk { chunk_type, .. } => format!( "CRC mismatch in {} at offset {}", String::from_utf8_lossy(&chunk_type), seg.range.start - )), - SegmentKind::Truncated => Some(format!( + ), + SegmentKind::Truncated => format!( "truncated from offset {} ({} bytes)", seg.range.start, seg.range.len() - )), - SegmentKind::Trailer => Some(format!( + ), + _ => format!( "{} trailing bytes after IEND at offset {}", seg.range.len(), seg.range.start - )), - _ => None, + ), }) .collect(); - // A skip the file itself caused is a finding, and it is counted before the list is printed so - // the exit message cannot report "0 finding(s)" while exiting non-zero. An over-budget skip is - // not damage — nothing is known to be wrong with the file — so it is not one. + // A skip the file itself caused is damage. An over-budget skip is not — nothing is known to be + // wrong with the file — but it is still a reason this command cannot vouch for it, which is a + // separate question the verdict below keeps separate. if let FilterScan::Skipped(reason) = report.filters && reason.is_damage() { - damaged.push(format!( - "filters not counted — {}", - filter_skip_label(reason) - )); + findings += 1; + if damaged.len() < MAX_LIST { + damaged.push(format!( + "filters not counted — {}", + filter_skip_label(reason) + )); + } } - print_lines("findings", &damaged); + print_lines_of("findings", &damaged, findings); println!(" classified: {}", yes_no(report.is_fully_classified())); println!(" intact: {}", yes_no(report.is_intact())); - - if report.is_intact() { + println!(" verified: {}", yes_no(report.is_verified())); + + // The gate is `is_verified`, not `is_intact`. `is_intact` is "nothing is known against this + // file", which a file whose IDAT was never inflated satisfies without anything having been + // read — and a corrupt zlib payload under a valid CRC is exactly the damage only the scan + // sees. An archival gate that passed such a file would be reporting the reader's budget as a + // property of the file. + if report.is_verified() { Ok(()) + } else if report.is_intact() { + Err(CliError::NotFullyAccounted(format!( + "{}: not verified — {}", + path.display(), + report + .filters + .skipped() + .map_or("the filter scan did not run", filter_skip_label) + ))) } else { Err(CliError::NotFullyAccounted(format!( - "{}: not a complete, undamaged PNG datastream — {} finding(s)", + "{}: not a complete, undamaged PNG datastream — {findings} finding(s)", path.display(), - damaged.len() ))) } } From b20f9d4a1efdfb13e857c0e21d699012d68a883e Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 2 Sep 2026 17:39:06 -0400 Subject: [PATCH 38/54] feat(png): seal FilterStrategy, and clear only the bigram words a row dirtied `FilterStrategy` is public, re-exported through the umbrella, and gained two variants this branch -- which breaks any downstream exhaustive `match`. At 0.1.0 a minor bump is Cargo's breaking slot so nothing breaks today, and `#[non_exhaustive]` is free now and not later. It is also already the house style: the workspace uses it in 212 places, `SkippedFilterScan` and `PngReport` included. The bigram scorer wiped its whole 8 KiB bitset per candidate -- 40 KiB of memset per scanline at five candidates, independent of row length, which for an ordinary row is more work than the scoring it makes possible. `MinBigrams` is in `BRUTE_FORCE_STRATEGIES`, so `BruteForce` paid it too. The set now records the words it dirtied and clears only those: a row of n bytes touches at most n-1 of them. Byte-identical output; the `Scratch` doc no longer claims hoisting saves a cost that hoisting does not touch. --- crates/gamut-png/src/filter.rs | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/crates/gamut-png/src/filter.rs b/crates/gamut-png/src/filter.rs index ce0b5739..d9177efa 100644 --- a/crates/gamut-png/src/filter.rs +++ b/crates/gamut-png/src/filter.rs @@ -22,7 +22,12 @@ pub enum FilterType { } /// How the encoder chooses a filter for each scanline (a space/time trade-off). +/// +/// Non-exhaustive: a heuristic is a measurement result, and this crate's own `STATUS.md` records +/// the corpus that decides which ones are worth shipping — so the set grows as that corpus does. +/// Match with a wildcard arm. #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] pub enum FilterStrategy { /// Filter every scanline with [`FilterType::None`] (fastest; good for already-random data). None, @@ -205,13 +210,19 @@ enum Score { /// Scratch a scorer needs, allocated once per image rather than per scanline. /// -/// The bigram set is 8 KiB of bitset; rebuilding it per row would dominate the measurement it is -/// supposed to make cheap. +/// Hoisting saves the *allocation*; it does not on its own save the clearing, and the clearing is +/// the larger cost. The bigram set is 8 KiB, so wiping it wholesale would cost 8 KiB per candidate +/// and five candidates per scanline — 40 KiB of memset per row, independent of how long the row +/// is, which for any ordinary row is more work than the scoring. So the set records which words it +/// dirtied and clears only those: a row of `n` bytes touches at most `n - 1` of them. struct Scratch { /// Byte histogram for [`Score::Entropy`]. histogram: [u32; 256], /// One bit per (previous, current) byte pair for [`Score::Bigrams`]. bigrams: Vec, + /// The indices of the `bigrams` words this scorer set, so the reset touches only them. Holds + /// each dirtied word exactly once — a word is pushed when it goes from all-zero to non-zero. + dirty: Vec, } impl Scratch { @@ -219,6 +230,7 @@ impl Scratch { Self { histogram: [0; 256], bigrams: vec![0; 1 << 10], + dirty: Vec::new(), } } } @@ -256,7 +268,6 @@ fn score(kind: Score, filtered: &[u8], scratch: &mut Scratch) -> u64 { (bits * 256.0) as u64 } Score::Bigrams => { - scratch.bigrams.fill(0); let mut distinct = 0u64; for pair in filtered.windows(2) { // The pair *is* a big-endian `u16`, so read it as one. Spelling it `a << 8 | b` @@ -266,10 +277,20 @@ fn score(kind: Score, filtered: &[u8], scratch: &mut Scratch) -> u64 { let index = usize::from(u16::from_be_bytes([pair[0], pair[1]])); let (word, bit) = (index >> 6, index & 63); if scratch.bigrams[word] & (1 << bit) == 0 { + // Record the word the first time it leaves zero, so `dirty` lists each + // dirtied word once and the reset below is exact. + if scratch.bigrams[word] == 0 { + scratch.dirty.push(word); + } scratch.bigrams[word] |= 1 << bit; distinct += 1; } } + // Leave the set all-zero for the next candidate, touching only what was dirtied. + for &word in &scratch.dirty { + scratch.bigrams[word] = 0; + } + scratch.dirty.clear(); distinct } } From 5a363fc59dedb4e6046d2ecfc9c48f118ec19aa5 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 2 Sep 2026 17:39:06 -0400 Subject: [PATCH 39/54] refactor(png): drop a palette sort key that cannot change the order `ordered_palette` sorted by `(c[3] == 255, c[3], luma)`. The first component is monotone non-decreasing in the second over 0..=255, so it orders every pair the way `c[3]` alone already does and can never change the result -- 255 being the maximum is exactly why ordering by alpha *is* "opaque last". A tuple component no input can make load-bearing is the kind of branch this repository's mutation policy exists to keep out. --- crates/gamut-png/src/reduce.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/gamut-png/src/reduce.rs b/crates/gamut-png/src/reduce.rs index 7daf1a11..98a76c8d 100644 --- a/crates/gamut-png/src/reduce.rs +++ b/crates/gamut-png/src/reduce.rs @@ -437,8 +437,10 @@ fn ordered_palette(palette: &[[u8; 4]]) -> Vec<[u8; 4]> { let mut out = palette.to_vec(); out.sort_by_key(|c| { let luma = 299 * u32::from(c[0]) + 587 * u32::from(c[1]) + 114 * u32::from(c[2]); - // Opaque entries sort after every transparent one; within each group, by alpha then luma. - (u32::from(c[3] == 255), u32::from(c[3]), luma) + // Alpha first, so every transparent entry sorts ahead of every opaque one -- 255 is the + // maximum, so ordering by alpha *is* "opaque last" and a separate `c[3] == 255` component + // ahead of it can never change the order this returns. + (u32::from(c[3]), luma) }); out } From bec1e5bf7d4e268f3b109ea8753a6122a6b35181 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 2 Sep 2026 17:39:06 -0400 Subject: [PATCH 40/54] test(png): pin the greyscale colour key the race declines `a_greyscale_colour_key_drops_the_alpha_channel_losslessly` proves `GrayKeyed` is reachable, but its fixture wins at every size, so dropping `GrayKeyed` from `write_reduced_or_native`'s `carries_chunks` set -- emitting the keyed file without racing it -- would not change its result. Nothing else in the suite could see that member. Losing needs a thinner saving than truecolour's: the `tRNS` costs a flat 14 bytes while dropping the alpha plane saves one byte per pixel, so a mostly-opaque image is where the fixed cost wins. A quarter-width transparent border at 16x16 measures 88 bytes as `GrayAlpha8` against 97 for the key, and the encoder must emit the 88. --- crates/gamut-png/tests/colour_key.rs | 68 +++++++++++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/crates/gamut-png/tests/colour_key.rs b/crates/gamut-png/tests/colour_key.rs index 080a76ca..b7e072e6 100644 --- a/crates/gamut-png/tests/colour_key.rs +++ b/crates/gamut-png/tests/colour_key.rs @@ -8,7 +8,7 @@ mod common; -use gamut_core::{Dimensions, EncodeImage, GrayAlpha8, ImageRef, Rgb8, Rgba8}; +use gamut_core::{Dimensions, EncodeImage, Gray8, GrayAlpha8, ImageRef, Rgb8, Rgba8}; use gamut_png::{FilterStrategy, Level, PngEncoder, deconstruct}; /// 128, not something smaller, and the reason is the whole design of the reduction. @@ -29,6 +29,9 @@ const SIDE: u32 = 128; /// The 18 bytes a truecolour `tRNS` adds to an encoding: 4 length + 4 type + 6 payload + 4 CRC. const TRNS_RGB_CHUNK: usize = 18; +/// The 14 bytes a greyscale `tRNS` adds: the same framing over a single 16-bit sample. +const TRNS_GRAY_CHUNK: usize = 14; + fn encode(samples: &[u8]) -> Vec { encode_at(SIDE, samples) } @@ -308,6 +311,69 @@ fn a_greyscale_colour_key_drops_the_alpha_channel_losslessly() { assert_eq!(rgba, expected, "the grey colour key resolves losslessly"); } +/// The greyscale twin of [`a_colour_key_that_would_cost_bytes_is_declined`], and the only test +/// that can see the `GrayKeyed` member of `write_reduced_or_native`'s `carries_chunks` set. +/// +/// [`a_greyscale_colour_key_drops_the_alpha_channel_losslessly`] proves `GrayKeyed` is *reachable*, +/// but its fixture wins at every size, so dropping `GrayKeyed` from `carries_chunks` -- emitting +/// the keyed file with no race -- would not change its result. Losing needs a thinner saving: the +/// `tRNS` costs a flat 14 bytes while dropping the alpha plane saves only one byte per pixel, so a +/// mostly-opaque image is where the fixed cost wins. A quarter-width transparent border at 16x16 +/// measures 88 bytes as `GrayAlpha8` against 97 for the key, and the encoder must emit the 88. +#[test] +fn a_greyscale_colour_key_that_would_cost_bytes_is_declined() { + const SMALL: u32 = 16; + // Mostly opaque, so the alpha plane the key removes is cheap to keep. Grey 7 is the invisible + // colour and the visible ramp starts at 8, so the key is valid -- only its cost declines it. + let mut src = Vec::with_capacity((SMALL * SMALL * 2) as usize); + for y in 0..SMALL { + for x in 0..SMALL { + if x < SMALL / 4 || y < SMALL / 4 { + src.extend_from_slice(&[7, 0]); + } else { + src.extend_from_slice(&[8 + ((x + y) % 200) as u8, 255]); + } + } + } + let dims = Dimensions::new(SMALL, SMALL).expect("valid dimensions"); + let encoder = || { + PngEncoder::new() + .with_compression(Level::Best) + .with_filter(FilterStrategy::BruteForce) + }; + let mut chosen = Vec::new(); + encoder() + .with_auto_reduce(true) + .encode_image( + ImageRef::::new(&src, dims).expect("buffer matches dimensions"), + &mut chosen, + ) + .expect("encode"); + assert_eq!( + libpng_oracle::decode(&chosen).color_type, + libpng_oracle::COLOR_GRAY_ALPHA, + "the key is valid at this size, so only its cost can have declined it" + ); + + // What the key would have cost: the grey plane alone through the same configuration, plus the + // flat `tRNS`. Reproducible from outside exactly as the truecolour twin does it. + let grey: Vec = src.as_chunks::<2>().0.iter().map(|px| px[0]).collect(); + let mut keyed = Vec::new(); + encoder() + .with_auto_reduce(false) + .encode_image( + ImageRef::::new(&grey, dims).expect("buffer matches dimensions"), + &mut keyed, + ) + .expect("encode"); + let keyed_len = keyed.len() + TRNS_GRAY_CHUNK; + assert!( + keyed_len > chosen.len(), + "the declined candidate must really be the larger one: keyed {keyed_len} vs GrayAlpha8 {}", + chosen.len() + ); +} + /// The *losing* side of the race in `write_reduced_or_native`, which its `carries_chunks` set /// exists for. /// From 423a0e638a6df2265fbe9f3f7764b64145587241 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 2 Sep 2026 17:39:21 -0400 Subject: [PATCH 41/54] docs(png): correct the cost model and the size claim against the encoder The cost-model table was a pre-race snapshot presented as current. Its `gamut` column (451/511/564/715) matches the shipped encoder at no size -- measured totals are 364/465/563/726 -- and it reported a flat 273-byte `PLTE`+`tRNS` at every row when a palette is emitted at only one of them. 273 is itself pre-ordering: this branch's own transparent-first ordering took the `tRNS` from 57 alphas to 8, so the palette candidate's fixed cost is 224. Worse, the 273 was repeated as the written justification for the `palette64_rgba8` budget, in the file the branch presents as carrying a measured reason per case. Retabulated from measurement, and restated to say what the three palette-less rows actually show: the raw estimate picks the palette at every one of these sizes, and the finished files disagree until 256, which is the argument for racing rather than estimating. `the_deflate_stage_accounts_for_the_residual_gap` is renamed to what it asserts. Landing on the same colour type makes `filtered_len` identical -- it is a function of IHDR alone -- but not the filtered bytes: gamut runs BruteForce while libpng runs its own heuristic, so the two compress different inputs and the ratio never isolated DEFLATE. The "smaller on every row" claim is qualified where it is a 0.2% near-tie on incompressible input, which is also the one row whose budget sits above parity and is excluded from the win assertion. The bench can now print `tie`, which `STATUS.md` recorded and the winner chain could not produce; and the module doc no longer tells the reader to pass a `--features test-support` flag that the crate's dev-dependency on itself already enables. --- crates/gamut-png/README.md | 4 ++- crates/gamut-png/STATUS.md | 36 ++++++++++++++++--------- crates/gamut-png/benches/encode.rs | 11 +++++--- crates/gamut-png/tests/size_contract.rs | 26 +++++++++++------- 4 files changed, 51 insertions(+), 26 deletions(-) diff --git a/crates/gamut-png/README.md b/crates/gamut-png/README.md index 128be4af..ab58f048 100644 --- a/crates/gamut-png/README.md +++ b/crates/gamut-png/README.md @@ -62,7 +62,9 @@ decoders must read identically — no vendored image corpus. A hand-crafted malf pins the rejection policy. Output size is measured against libpng at zlib level 9 by `cargo bench -p gamut-png`, and **enforced** by `tests/size_contract.rs`, whose per-case budgets each carry a written justification — a regression in the crate's reason to exist fails the build. -`STATUS.md` records the measured table; gamut is smaller than libpng-9 on every corpus entry. +`STATUS.md` records the measured table; gamut is smaller than libpng-9 on every corpus entry, by +28-85% wherever a reduction or a filter choice applies and by 0.2% on the incompressible noise row, +where there is nothing for either encoder to find. ## License diff --git a/crates/gamut-png/STATUS.md b/crates/gamut-png/STATUS.md index f6e366dd..e463b63a 100644 --- a/crates/gamut-png/STATUS.md +++ b/crates/gamut-png/STATUS.md @@ -81,7 +81,10 @@ absolute times**. | `flat_rgba8` | 262 144 | 821 | 103 | 103 | 664 | **−84.5%** | 0.013 | | `tiny_rgb8` (16×16) | 768 | 136 | 119 | 119 | 138 | **−13.8%** | 3.719 | -gamut is smaller than libpng-9 on every row. The margin is thin where no reduction applies +gamut is smaller than libpng-9 on every row, though `noise_rgb8` is a 0.2% near-tie rather than a +win: incompressible input leaves both encoders emitting stored blocks, so that row's budget is the +one deliberately set above parity (1.02) and it is excluded from the win assertion. The margin is +thin where no reduction applies (`gradient`, `tiny`) or nothing is compressible (`noise`), and large where a lawful representation change is available that libpng does not attempt. @@ -143,20 +146,27 @@ byte) plus removing a sixth redundant filter pass per scanline. `reduce::analyze8` chooses by comparing **raw** sizes, which does not predict compressed size when one candidate's bytes are incompressible and the other's are not. A palette carries a `PLTE` (and often `tRNS`) that DEFLATE cannot touch, while the pixels it replaces may compress by two orders of -magnitude. Measured on `palette64_rgba8`, where `PLTE` + `tRNS` is a flat 273 bytes: +magnitude. Measured on `palette64_rgba8`, whose palette candidate carries a flat 224 bytes of +`PLTE` + `tRNS` (192 + 8 payload, 24 framing) at every size — the fixture's colour count does not +depend on its side: -| side | gamut | IDAT | PLTE+tRNS | libpng-9 | +| side | emitted | IDAT | PLTE+tRNS emitted | libpng-9 | | --- | --- | --- | --- | --- | -| 128 | 451 | 121 | 273 | 405 | -| 160 | 511 | 181 | 273 | 572 | -| 192 | 564 | 234 | 273 | 707 | -| 256 | 715 | 385 | 273 | 1 102 | - -The estimate sees 16 664 against 65 536 and picks the palette by 4×; the finished files cross over -near 160×160. So `write_reduced_or_native` encodes both candidates and keeps the smaller, the same -way `FilterStrategy::BruteForce` already resolves filters — no tuned constant, and never worse than -either candidate alone. Only palette reductions pay for the second encode; greyscale, alpha-drop -and 16→8 demotion add no chunks, so for them the raw comparison is sound. +| 128 | 364 | 307 | — palette declined | 405 | +| 160 | 465 | 408 | — palette declined | 572 | +| 192 | 563 | 506 | — palette declined | 707 | +| 256 | 726 | 445 | 224 | 1 102 | + +The raw-size estimate sees 16 664 against 65 536 and picks the palette by 4× **at every one of +these sizes**. The finished files disagree: the palette's 224 fixed bytes are incompressible while +the pixels they replace compress by two orders of magnitude, so indexing only pays once the image +is large enough to amortise them — the crossover sits between 192 and 256. So +`write_reduced_or_native` encodes both candidates and keeps the smaller, the same way +`FilterStrategy::BruteForce` already resolves filters — no tuned constant, and never worse than +either candidate alone. The three declined rows are the evidence: had the estimate been trusted, +each would have carried a palette and been larger. Only palette reductions pay for the second +encode; greyscale, alpha-drop and 16→8 demotion add no chunks, so for them the raw comparison is +sound. [#437]: https://github.com/visualcommons/gamut/issues/437 [#478]: https://github.com/visualcommons/gamut/issues/478 diff --git a/crates/gamut-png/benches/encode.rs b/crates/gamut-png/benches/encode.rs index a66f5eac..821c5ed4 100644 --- a/crates/gamut-png/benches/encode.rs +++ b/crates/gamut-png/benches/encode.rs @@ -11,8 +11,9 @@ //! the colour-type choice, or to DEFLATE. //! //! Counters report bytes of *source* pixels per second, so figures are comparable with the other -//! codec suites. Run with `cargo bench -p gamut-png` (or `mise run bench`); add -//! `--features test-support` for the per-stage rows. +//! codec suites. Run with `cargo bench -p gamut-png` (or `mise run bench`). The per-stage rows +//! need this crate's `test-support` feature, which its own dev-dependency on itself already +//! enables for every test and bench build -- there is no flag to pass. //! //! Intentionally tight: this measures **encoding**, on a generated 8-bit corpus, and nothing else. //! There is no decode axis -- `PngDecoder`'s cost is a separate question against a separate @@ -275,7 +276,11 @@ fn print_heuristic_table() { of(FilterStrategy::MinBigrams), ); let best = msa.min(ent).min(big); - let winner = if best == msa { + // A three-way tie is a real outcome on incompressible input, and naming the first + // heuristic the winner there would record a preference the measurement did not find. + let winner = if msa == ent && ent == big { + "tie" + } else if best == msa { "MinSumAbs" } else if best == ent { "Entropy" diff --git a/crates/gamut-png/tests/size_contract.rs b/crates/gamut-png/tests/size_contract.rs index 243a4a85..0bad0a4b 100644 --- a/crates/gamut-png/tests/size_contract.rs +++ b/crates/gamut-png/tests/size_contract.rs @@ -147,10 +147,11 @@ const BUDGETS: &[Budget] = &[ max_ratio: 0.95, measured: 0.899, why: "64 colours over two alpha levels. The palette encoding wins outright at 256x256 \ - but loses at this size, because PLTE + tRNS is a flat 273 incompressible bytes \ + but loses at this size, because PLTE + tRNS is a flat 224 incompressible bytes \ against pixels that compress ~160x; `write_reduced_or_native` encodes both and \ - keeps the smaller, so the row measures whichever is actually better here. The race \ - is what makes the outcome stable enough to budget below 1.00.", + keeps the smaller, so the row measures whichever is actually better here -- at \ + 128x128 that is the unreduced encoding, which carries no PLTE at all. The race is \ + what makes the outcome stable enough to budget below 1.00.", }, Budget { name: "palette64_rgba8 +clean", @@ -303,11 +304,18 @@ fn gamut_beats_libpng9_where_it_claims_to() { } #[test] -fn the_deflate_stage_accounts_for_the_residual_gap() { - // The attribution test, and the reason `deconstruct` is a dependency of this file. Where both - // encoders land on the same colour type and depth, the filtered stream is identical by - // construction, so the ratio of the *compressed* streams isolates DEFLATE from filtering and - // from the colour-type choice. Only the rows where no reduction applies can say this. +fn the_codestream_is_no_larger_where_both_encoders_choose_the_same_representation() { + // The reason `deconstruct` is a dependency of this file: it reads the IDAT total out of both + // encoders' output, so the comparison is over codestreams rather than whole files, with + // framing and chunk differences excluded. + // + // This is deliberately *not* an attribution to DEFLATE. Landing on the same colour type and + // depth makes `filtered_len` identical -- it is a function of IHDR alone -- but not the + // filtered *bytes*: gamut runs `BruteForce` (MinBigrams wins `gradient_rgb8`) while libpng + // runs its own adaptive heuristic, so the two compress different inputs. What is asserted is + // the combined result of filtering and DEFLATE, which is what the size claim rests on anyway; + // isolating the DEFLATE stage would mean re-filtering libpng's pixels with gamut's own + // choices first. Only the rows where no reduction applies can be compared at all. for name in ["gradient_rgb8", "photo_rgb8"] { let (samples, channels) = pixels(name, SIDE); let ours = gamut_best(&samples, channels, SIDE, false); @@ -328,7 +336,7 @@ fn the_deflate_stage_accounts_for_the_residual_gap() { ); assert!( a.idat_compressed <= b.idat_compressed, - "{name}: gamut's DEFLATE stage produced {} bytes against libpng-9's {}", + "{name}: gamut's codestream is {} bytes against libpng-9's {}", a.idat_compressed, b.idat_compressed, ); From 332af8de2fa1f67d6cdd3b1c8294f9ec3290de0d Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 2 Sep 2026 17:39:21 -0400 Subject: [PATCH 42/54] docs: record the crc32fast approval for gamut-png The rule is "maintainer-approved external crates", and the approval for this one lived nowhere outside the diff that added it. Recorded where the rule is, with what it buys and why it does not cost the crate its safety posture. --- AGENTS.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index c30149d5..1ad44c46 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,7 +70,10 @@ Dependency edges (a crate depends on those to its right): hostile input, ancillary metadata surfaced as raw `MetadataBlock`-ready payloads (eXIf/iCCP/XMP/text) plus parsed gAMA/cHRM/sRGB/cICP. APNG out of scope (decodes as the default image). Differential oracle both directions: libpng, which also *generates* the - decoder's conformance fixtures. ← core, deflate (+ `miniz_oxide` for inflate). + decoder's conformance fixtures. ← core, deflate (+ `miniz_oxide` for inflate, and + **maintainer-approved `crc32fast`** for the chunk CRC that every encode pays on its critical + path — hardware CRC-32 on x86-64/aarch64, table fallback elsewhere including wasm32, and it + keeps its `unsafe` to itself, so gamut-png stays `#![deny(unsafe_code)]`). - **gamut-ifd** — TIFF/IFD container core (byte order, field types, IFD read/write); a low-level container primitive (sibling to bitstream), shared by `gamut-tiff` and EXIF metadata. ← core. Optional `bigtiff` feature adds 64-bit BigTIFF. Per-format metadata From 97567f5848ef9bdebbba3ef6641df595fb7af621 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 2 Sep 2026 18:13:55 -0400 Subject: [PATCH 43/54] test(png): kill the five mutants the new walk code left alive The incremental mutation gate found five survivors in the previous commits, all of them gaps in the tests rather than in the code. `is_counted` and `is_verified` were pinned only by their negative cases -- an over-budget file, which satisfies every assertion those made even when both predicates are hardcoded `false`. A verdict a gate depends on was one that could always have said no. Both now have the positive case as well. `with_max_image_bytes` was never exercised: the ceiling test only ever set `max_chunks`, so replacing the setter with `Default::default()` changed nothing, and `deconstruct_with_limits` was `deconstruct` with extra steps. A one-byte budget over an ordinary file now makes the caller's choice observable. The chunk ceiling was asserted far past the boundary, where `>`, `>=` and `==` are indistinguishable -- any file well over the limit is refused by all three. It now asserts the exact count: a file of precisely the ceiling's size is admitted, and one more is refused. Each of the five was re-applied by hand against this suite to confirm it now fails. --- crates/gamut-png/tests/accounting.rs | 62 ++++++++++++++++++++++++---- 1 file changed, 53 insertions(+), 9 deletions(-) diff --git a/crates/gamut-png/tests/accounting.rs b/crates/gamut-png/tests/accounting.rs index 5afd2f8a..fa098bf0 100644 --- a/crates/gamut-png/tests/accounting.rs +++ b/crates/gamut-png/tests/accounting.rs @@ -553,10 +553,12 @@ fn an_unread_file_is_intact_but_not_verified() { } #[test] -fn a_file_past_the_chunk_ceiling_is_refused() { +fn the_chunk_ceiling_admits_exactly_its_own_count_and_refuses_one_more() { // The chunk count is chosen by the input -- a chunk costs 12 bytes and buys a segment -- so - // the walk caps it. Below the ceiling the same file reports normally, which is what keeps the - // cap from being a refusal to measure. + // the walk caps it. Asserted *at the boundary* rather than far past it: a file well over the + // ceiling is refused by `>`, `>=` and `==` alike, so only the exact count separates them. + // Eleven segments here: the signature, IHDR, eight fillers and IEND. + const SEGMENTS: usize = 11; let mut chunks = vec![common::chunk(b"IHDR", &common::ihdr_payload(1, 1, 8, 0, 0))]; for _ in 0..8 { chunks.push(common::chunk(b"crUD", &[])); @@ -564,19 +566,61 @@ fn a_file_past_the_chunk_ceiling_is_refused() { chunks.push(common::chunk(b"IEND", &[])); let png = common::png_from_chunks(&chunks); - let generous = DeconstructLimits::default().with_max_chunks(100); - let report = deconstruct_with_limits(&png, generous).expect("under the ceiling"); - assert_eq!(report.segments.len(), 11, "signature plus ten chunks"); + let exact = DeconstructLimits::default().with_max_chunks(SEGMENTS); + let report = deconstruct_with_limits(&png, exact) + .expect("a file of exactly the ceiling's size is admitted, not refused"); + assert_eq!(report.segments.len(), SEGMENTS); + assert!(report.is_fully_classified(), "and it reports normally"); - let stingy = DeconstructLimits::default().with_max_chunks(4); - let err = deconstruct_with_limits(&png, stingy) - .expect_err("past the ceiling the walk refuses rather than allocating"); + let one_short = DeconstructLimits::default().with_max_chunks(SEGMENTS - 1); + let err = deconstruct_with_limits(&png, one_short) + .expect_err("one past the ceiling the walk refuses rather than allocating"); assert!( err.to_string().contains("more chunks"), "the error names the ceiling it hit, got: {err}" ); } +#[test] +fn the_image_budget_is_the_callers_to_set() { + // `with_max_image_bytes` has to be observable, or the walk silently keeps the decoder's + // default and `deconstruct_with_limits` is `deconstruct` with extra steps. A one-byte budget + // turns an ordinary small file -- comfortably scanned under the default -- into a refusal. + let png = common::minimal_png(); + assert!( + deconstruct(&png).expect("deconstruct").filters.is_counted(), + "the fixture is scanned under the default budget" + ); + + let stingy = DeconstructLimits::default().with_max_image_bytes(1); + let report = deconstruct_with_limits(&png, stingy).expect("a budget refusal is not an error"); + assert_eq!( + report.filters, + FilterScan::Skipped(SkippedFilterScan::OverBudget), + "the caller's budget decides, not the decoder's default" + ); +} + +#[test] +fn a_sound_file_is_both_read_and_verified() { + // The positive side of `is_counted` and `is_verified`. Without it both can be pinned to + // `false` by the negative cases alone -- an over-budget file satisfies every assertion they + // make -- and the verdict a gate depends on would be one that always says no. + let png = common::minimal_png(); + let report = deconstruct(&png).expect("deconstruct"); + + assert!( + report.filters.is_counted(), + "a sound stream is read, not skipped" + ); + assert!(report.filters.histogram().is_some(), "so it has counts"); + assert!(report.is_intact(), "and nothing is held against it"); + assert!( + report.is_verified(), + "which together with having been read is what verification means" + ); +} + #[test] fn an_over_budget_image_reports_everything_but_the_histogram() { // A hand-built IHDR claiming 2^30 x 2^30 with a tiny IDAT: the image it implies is far past From 49189a6cba3957013ed78668eecd0c4ef6a704b1 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Sat, 5 Sep 2026 06:15:50 -0400 Subject: [PATCH 44/54] fix(png): emit bKGD and sBIT for the colour type actually written MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `write_png` emitted the `Ancillary` bag verbatim whatever colour type it wrote, and auto-reduce can write a different one from the input's: the palette and colour-key candidates are raced against the unreduced encoding on compressed size, so which colour type lands is not knowable when `with_background_index` or `with_significant_bits` is called. A one-byte `bKGD` under colour type 6, or a four-entry `sBIT` under colour type 2, is a chunk libpng rejects (`png_handle_bKGD` / `png_handle_sBIT`: the length must match the colour type, an index must be inside the palette, every value must fit the depth) and silently drops. Both chunks are now resolved against the header actually written, in `ancillary::bkgd_for` and `ancillary::sbit_for`: a lossless conversion where one exists — RGBA `sBIT` loses its alpha entry, an RGB or grey background under a palette becomes the index of the entry holding it, a grey RGB triple collapses to one grey sample, and the reverse where the channels agree — and omission otherwise, including a sample or bit count the written depth cannot hold. `write_png` takes a `WrittenHeader` (colour type, depth, palette) so both writers see the same header. The pre-existing encoder test pinned a grey `bKGD` of 0x1234 under an 8-bit image — a chunk libpng drops — and an index under a truecolour file it never referred to; it now pins the same builders on colours the written file can carry, through `encode_indexed8` for the index. The libpng oracle exposes neither chunk nor a warning count, so the new integration tests assert the emitted payload against libpng's acceptance rules and decode every file through libpng; the conversion rules themselves are pinned inline. --- crates/gamut-png/src/ancillary.rs | 350 +++++++++++++++++- crates/gamut-png/src/encoder.rs | 158 +++++--- .../gamut-png/tests/ancillary_colour_type.rs | 248 +++++++++++++ 3 files changed, 693 insertions(+), 63 deletions(-) create mode 100644 crates/gamut-png/tests/ancillary_colour_type.rs diff --git a/crates/gamut-png/src/ancillary.rs b/crates/gamut-png/src/ancillary.rs index 44a3d4ae..1236dc59 100644 --- a/crates/gamut-png/src/ancillary.rs +++ b/crates/gamut-png/src/ancillary.rs @@ -2,10 +2,19 @@ //! //! These are optional. The encoder accumulates whatever the caller sets and emits the chunks in the //! order PNG requires (Table 7): colour-space chunks before `PLTE`, the rest before `IDAT`. +//! +//! Two of them, `bKGD` and `sBIT`, have a payload whose shape is the image's colour type, and the +//! encoder does not always write the colour type the caller set them for: auto-reduce may write a +//! palette, a greyscale or a colour-keyed truecolour image in place of the input's layout, and the +//! palette and colour-key candidates are *raced* against the unreduced encoding on compressed +//! size, so which one lands is not knowable when the chunk is set. Both are therefore emitted for +//! the header actually written — converted where a lossless conversion exists, omitted otherwise +//! ([`bkgd_for`], [`sbit_for`]) — rather than verbatim, because a payload shaped for the wrong +//! colour type is a chunk a reader rejects and drops. use gamut_deflate::{DeflateEncoder, Level}; -use crate::chunk; +use crate::{ColorType, chunk}; /// The rendering intent for an `sRGB` chunk (PNG spec §11.3.3.5). #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -139,8 +148,9 @@ impl Ancillary { } /// Emits the colour-space chunks that must precede `PLTE` (PNG Table 7). `effort` is the - /// encoder's [`Level::Best`] budget, applied to the compressed `iCCP` payload. - pub(crate) fn write_pre_plte(&self, out: &mut Vec, effort: u8) { + /// encoder's [`Level::Best`] budget, applied to the compressed `iCCP` payload; `written` is + /// the IHDR these chunks sit under, which `sBIT` must agree with. + pub(crate) fn write_pre_plte(&self, out: &mut Vec, effort: u8, written: WrittenHeader<'_>) { if let Some(chrm) = self.chrm { let mut data = [0u8; 32]; for (slot, value) in chrm.iter().enumerate() { @@ -161,8 +171,12 @@ impl Ancillary { .zlib_compress(profile, &mut data); chunk::write_chunk(out, *b"iCCP", &data); } - if let Some(sbit) = &self.sbit { - chunk::write_chunk(out, *b"sBIT", sbit); + if let Some(sbit) = self + .sbit + .as_deref() + .and_then(|sbit| sbit_for(sbit, written.color, written.bit_depth)) + { + chunk::write_chunk(out, *b"sBIT", &sbit); } if let Some(intent) = self.srgb { chunk::write_chunk(out, *b"sRGB", &[intent]); @@ -170,13 +184,23 @@ impl Ancillary { } /// Emits the remaining ancillary chunks that precede `IDAT` (after any `PLTE`/`tRNS`). - /// `effort` is the encoder's [`Level::Best`] budget, applied to compressed `zTXt` payloads. - pub(crate) fn write_post_plte(&self, out: &mut Vec, effort: u8) { + /// `effort` is the encoder's [`Level::Best`] budget, applied to compressed `zTXt` payloads; + /// `written` is the IHDR (and palette) these chunks sit under, which `bKGD` must agree with. + pub(crate) fn write_post_plte( + &self, + out: &mut Vec, + effort: u8, + written: WrittenHeader<'_>, + ) { if let Some(exif) = &self.exif { chunk::write_chunk(out, *b"eXIf", exif); } - if let Some(bkgd) = &self.bkgd { - chunk::write_chunk(out, *b"bKGD", bkgd); + if let Some(bkgd) = self + .bkgd + .as_deref() + .and_then(|bkgd| bkgd_for(bkgd, written)) + { + chunk::write_chunk(out, *b"bKGD", &bkgd); } if let Some((x, y, unit)) = self.phys { let mut data = [0u8; 9]; @@ -194,6 +218,131 @@ impl Ancillary { } } +/// The IHDR — and, for an indexed image, the `PLTE` payload — the ancillary chunks are written +/// under: what a colour-type-shaped payload has to agree with. +#[derive(Debug, Clone, Copy)] +pub(crate) struct WrittenHeader<'a> { + /// The colour type IHDR declares. + pub color: ColorType, + /// The bit depth IHDR declares. + pub bit_depth: u8, + /// The `PLTE` payload (RGB triples) for [`ColorType::Indexed`]; `None` otherwise. + pub plte: Option<&'a [u8]>, +} + +impl WrittenHeader<'static> { + /// A header without a palette — every colour type but [`ColorType::Indexed`]. + pub(crate) const fn new(color: ColorType, bit_depth: u8) -> Self { + Self { + color, + bit_depth, + plte: None, + } + } +} + +/// The `bKGD` payload for the header actually written (§11.3.5.1), or `None` to omit the chunk. +/// +/// The caller's payload names its own colour type by its length — one byte is a palette index, +/// two a grey sample, six an RGB triple, each sample 16-bit big-endian — and is converted where +/// the written header can carry the same colour losslessly: +/// +/// - a grey sample and an RGB triple whose channels agree are the same colour, either way round; +/// - an RGB or grey colour under a palette becomes the index of the entry holding it — which +/// exists whenever the background colour occurs in the image, since the palette is built from +/// the image — and is omitted when no entry does; +/// - a palette index names a colour only inside a palette. Under a written palette it is kept +/// when it is in range; under any other colour type there is no palette it refers to (the one +/// caller-supplied palette path, `encode_indexed8`, always writes indexed), so it is omitted; +/// - a grey or RGB sample must fit the written depth (`value < 1 << depth` below 16 bits); one +/// that does not is omitted rather than written as a chunk the reader rejects. +/// +/// The rules are the ones a reader applies before honouring the chunk — libpng's +/// `png_handle_bKGD` rejects a wrong length, an index past the palette and a sample past the +/// depth — so "converted or omitted" means "never dropped on read". +pub(crate) fn bkgd_for(bkgd: &[u8], written: WrittenHeader<'_>) -> Option> { + let sample = |hi: u8, lo: u8| u16::from_be_bytes([hi, lo]); + let rgb: [u16; 3] = match *bkgd { + [index] => { + let entries = written.plte.map_or(0, |plte| plte.len() / 3); + return (written.color == ColorType::Indexed && usize::from(index) < entries) + .then(|| vec![index]); + } + [hi, lo] => [sample(hi, lo); 3], + [r1, r0, g1, g0, b1, b0] => [sample(r1, r0), sample(g1, g0), sample(b1, b0)], + _ => return None, + }; + match written.color { + ColorType::Indexed => { + let entry = rgb.map(|v| u8::try_from(v).ok()); + let entry = [entry[0]?, entry[1]?, entry[2]?]; + let index = written + .plte? + .as_chunks::<3>() + .0 + .iter() + .position(|e| *e == entry)?; + u8::try_from(index).ok().map(|index| vec![index]) + } + ColorType::Grayscale | ColorType::GrayscaleAlpha => { + let grey = (rgb[0] == rgb[1] && rgb[1] == rgb[2]).then_some(rgb[0])?; + fits_depth(grey, written.bit_depth).then(|| grey.to_be_bytes().to_vec()) + } + ColorType::Truecolor | ColorType::TruecolorAlpha => rgb + .iter() + .all(|&v| fits_depth(v, written.bit_depth)) + .then(|| rgb.iter().flat_map(|v| v.to_be_bytes()).collect()), + } +} + +/// Whether a 16-bit-framed `bKGD` sample is in range for the written depth: any value at 16 bits, +/// below `1 << depth` otherwise (libpng rejects `buf[0] != 0 || buf[1] >= 1 << bit_depth`). +fn fits_depth(value: u16, bit_depth: u8) -> bool { + bit_depth >= 16 || u32::from(value) < 1u32 << bit_depth +} + +/// The `sBIT` payload for the header actually written (§11.3.3.4), or `None` to omit the chunk. +/// +/// The caller's payload names its own colour type by its length — one entry for grey, two for +/// grey+alpha, three for RGB (and for a palette, whose entries are RGB), four for RGBA — and is +/// converted where every channel the written image has is described: +/// +/// - dropping a channel the written image no longer has is lossless — RGBA to RGB or to a palette +/// drops the alpha entry, RGB to grey keeps the one value the three agreed on; +/// - grey and RGB are interchangeable where the three RGB entries agree; +/// - an alpha entry cannot be invented, so a payload without one is omitted under an alpha +/// colour type — a case no reduction reaches, since reductions only drop channels. +/// +/// Every entry must then be `1..=depth`, where a palette's depth is that of its 8-bit entries +/// (libpng rejects `buf[i] == 0 || buf[i] > maxbits`). An entry the written depth cannot hold is +/// omitted with the chunk: a claim of twelve significant bits over an image demoted to eight is +/// not one the file can carry. +pub(crate) fn sbit_for(sbit: &[u8], color: ColorType, bit_depth: u8) -> Option> { + let (rgb, alpha) = match *sbit { + [g] => ([g; 3], None), + [g, a] => ([g; 3], Some(a)), + [r, g, b] => ([r, g, b], None), + [r, g, b, a] => ([r, g, b], Some(a)), + _ => return None, + }; + let grey = || (rgb[0] == rgb[1] && rgb[1] == rgb[2]).then_some(rgb[0]); + let entries = match color { + ColorType::Grayscale => vec![grey()?], + ColorType::GrayscaleAlpha => vec![grey()?, alpha?], + ColorType::Truecolor | ColorType::Indexed => rgb.to_vec(), + ColorType::TruecolorAlpha => vec![rgb[0], rgb[1], rgb[2], alpha?], + }; + let max_bits = if color == ColorType::Indexed { + 8 + } else { + bit_depth + }; + entries + .iter() + .all(|&bits| (1..=max_bits).contains(&bits)) + .then_some(entries) +} + /// Serialises one text chunk (tEXt / zTXt / iTXt). fn write_text(out: &mut Vec, entry: &TextEntry, effort: u8) { match entry.kind { @@ -230,6 +379,13 @@ fn write_text(out: &mut Vec, entry: &TextEntry, effort: u8) { mod tests { use super::*; + /// The header the pre-existing serialisation tests were written against: 8-bit truecolour. + const RGB8: WrittenHeader<'static> = WrittenHeader { + color: ColorType::Truecolor, + bit_depth: 8, + plte: None, + }; + fn find_chunk(png: &[u8], ty: &[u8; 4]) -> Option> { // Walk the chunk stream (after the 8-byte signature) and return a chunk's data. let mut i = 8; @@ -270,7 +426,7 @@ mod tests { ..Default::default() }; let mut out = vec![0u8; 8]; // fake signature - a.write_pre_plte(&mut out, DeflateEncoder::DEFAULT_EFFORT); + a.write_pre_plte(&mut out, DeflateEncoder::DEFAULT_EFFORT, RGB8); assert_eq!( find_chunk(&out, b"gAMA"), Some(45455u32.to_be_bytes().to_vec()) @@ -286,7 +442,7 @@ mod tests { a.set_time(2026, 6, 13, 1, 2, 3); a.add_text_latin1("Title", "hi"); let mut out = vec![0u8; 8]; - a.write_post_plte(&mut out, DeflateEncoder::DEFAULT_EFFORT); + a.write_post_plte(&mut out, DeflateEncoder::DEFAULT_EFFORT, RGB8); let phys = find_chunk(&out, b"pHYs").unwrap(); assert_eq!(&phys[0..4], 2835u32.to_be_bytes()); assert_eq!(phys[8], 1); // metre @@ -305,14 +461,14 @@ mod tests { ..Default::default() }; let mut pre = vec![0u8; 8]; - a.write_pre_plte(&mut pre, DeflateEncoder::DEFAULT_EFFORT); + a.write_pre_plte(&mut pre, DeflateEncoder::DEFAULT_EFFORT, RGB8); let iccp = find_chunk(&pre, b"iCCP").unwrap(); assert_eq!(&iccp[..2], b"p\0"); // profile name + null assert_eq!(iccp[2], 0); // compression method assert_eq!(iccp[3], 0x78); // zlib CMF byte begins the compressed profile let mut post = vec![0u8; 8]; - a.write_post_plte(&mut post, DeflateEncoder::DEFAULT_EFFORT); + a.write_post_plte(&mut post, DeflateEncoder::DEFAULT_EFFORT, RGB8); assert_eq!( find_chunk(&post, b"eXIf").unwrap(), vec![0x49, 0x49, 0x2A, 0x00] @@ -326,10 +482,176 @@ mod tests { let mut a = Ancillary::default(); a.add_text_compressed("Comment", "the quick brown fox"); let mut out = vec![0u8; 8]; - a.write_post_plte(&mut out, DeflateEncoder::DEFAULT_EFFORT); + a.write_post_plte(&mut out, DeflateEncoder::DEFAULT_EFFORT, RGB8); let data = find_chunk(&out, b"zTXt").unwrap(); assert_eq!(&data[..8], b"Comment\0"); assert_eq!(data[8], 0); // compression method assert_eq!(data[9], 0x78); // the zlib CMF byte begins the compressed text } + + fn header(color: ColorType, bit_depth: u8) -> WrittenHeader<'static> { + WrittenHeader { + color, + bit_depth, + plte: None, + } + } + + /// Three entries: red, a grey, blue. + const PLTE: [u8; 9] = [200, 30, 60, 77, 77, 77, 20, 90, 220]; + + fn indexed(bit_depth: u8) -> WrittenHeader<'static> { + WrittenHeader { + color: ColorType::Indexed, + bit_depth, + plte: Some(&PLTE), + } + } + + #[test] + fn a_background_index_survives_only_inside_a_palette_that_holds_it() { + assert_eq!(bkgd_for(&[2], indexed(2)), Some(vec![2])); + assert_eq!(bkgd_for(&[3], indexed(2)), None, "past the palette"); + // The caller's index refers to no palette the file carries. + assert_eq!(bkgd_for(&[0], header(ColorType::TruecolorAlpha, 8)), None); + assert_eq!(bkgd_for(&[0], header(ColorType::Grayscale, 8)), None); + } + + #[test] + fn a_colour_under_a_palette_becomes_the_index_of_its_entry() { + // RGB (20, 90, 220) is entry 2; grey 77 is entry 1; (1, 2, 3) is nowhere. + assert_eq!(bkgd_for(&[0, 20, 0, 90, 0, 220], indexed(8)), Some(vec![2])); + assert_eq!(bkgd_for(&[0, 77], indexed(8)), Some(vec![1])); + assert_eq!(bkgd_for(&[0, 1, 0, 2, 0, 3], indexed(8)), None); + // A 16-bit sample has no 8-bit palette entry. + assert_eq!(bkgd_for(&[1, 0, 1, 0, 1, 0], indexed(8)), None); + } + + #[test] + fn grey_and_rgb_backgrounds_convert_where_the_channels_agree() { + assert_eq!( + bkgd_for(&[0, 77, 0, 77, 0, 77], header(ColorType::Grayscale, 8)), + Some(vec![0, 77]) + ); + assert_eq!( + bkgd_for(&[0, 77, 0, 77, 0, 78], header(ColorType::GrayscaleAlpha, 8)), + None, + "not a grey" + ); + assert_eq!( + bkgd_for(&[0, 77], header(ColorType::Truecolor, 8)), + Some(vec![0, 77, 0, 77, 0, 77]) + ); + // Same colour type: byte for byte. + assert_eq!( + bkgd_for(&[0, 1, 0, 2, 0, 3], header(ColorType::TruecolorAlpha, 8)), + Some(vec![0, 1, 0, 2, 0, 3]) + ); + // A wrong-length payload has no colour type at all. + assert_eq!(bkgd_for(&[1, 2, 3], header(ColorType::Truecolor, 8)), None); + } + + #[test] + fn a_background_sample_must_fit_the_written_depth() { + // 256 does not fit depth 8 in either framing; anything fits depth 16. + assert_eq!(bkgd_for(&[1, 0], header(ColorType::Grayscale, 8)), None); + assert_eq!( + bkgd_for(&[1, 0], header(ColorType::Grayscale, 16)), + Some(vec![1, 0]) + ); + assert_eq!( + bkgd_for(&[0, 1, 0, 2, 1, 0], header(ColorType::Truecolor, 8)), + None + ); + // Sub-byte grey: 3 is the last code at depth 2, 4 is not one. + assert_eq!( + bkgd_for(&[0, 3], header(ColorType::Grayscale, 2)), + Some(vec![0, 3]) + ); + assert_eq!(bkgd_for(&[0, 4], header(ColorType::Grayscale, 2)), None); + assert!(fits_depth(255, 8)); + assert!(!fits_depth(256, 8)); + assert!(fits_depth(65535, 16)); + } + + #[test] + fn significant_bits_follow_the_written_channels() { + // Dropping a channel the written image no longer has. + assert_eq!( + sbit_for(&[5, 6, 5, 4], ColorType::Truecolor, 8), + Some(vec![5, 6, 5]) + ); + assert_eq!( + sbit_for(&[5, 6, 5, 4], ColorType::Indexed, 1), + Some(vec![5, 6, 5]), + "a palette's sBIT is three entries at any index depth" + ); + assert_eq!( + sbit_for(&[7, 7, 7, 4], ColorType::GrayscaleAlpha, 8), + Some(vec![7, 4]) + ); + assert_eq!(sbit_for(&[7, 7, 7], ColorType::Grayscale, 8), Some(vec![7])); + assert_eq!(sbit_for(&[7, 4], ColorType::Grayscale, 8), Some(vec![7])); + // Grey to RGB where the channels agree, and never to a differing RGB. + assert_eq!(sbit_for(&[7], ColorType::Truecolor, 8), Some(vec![7, 7, 7])); + assert_eq!(sbit_for(&[5, 6, 5], ColorType::Grayscale, 8), None); + // An alpha entry cannot be invented. + assert_eq!(sbit_for(&[5, 6, 5], ColorType::TruecolorAlpha, 8), None); + assert_eq!(sbit_for(&[7], ColorType::GrayscaleAlpha, 8), None); + // Same colour type: byte for byte; a wrong length has no colour type. + assert_eq!( + sbit_for(&[5, 6, 5, 4], ColorType::TruecolorAlpha, 8), + Some(vec![5, 6, 5, 4]) + ); + assert_eq!(sbit_for(&[], ColorType::Truecolor, 8), None); + assert_eq!(sbit_for(&[1, 2, 3, 4, 5], ColorType::Truecolor, 8), None); + } + + #[test] + fn a_significant_bit_count_is_one_to_the_written_depth() { + assert_eq!(sbit_for(&[8], ColorType::Grayscale, 8), Some(vec![8])); + assert_eq!( + sbit_for(&[9], ColorType::Grayscale, 8), + None, + "past the depth" + ); + assert_eq!( + sbit_for(&[0], ColorType::Grayscale, 8), + None, + "zero is not a count" + ); + assert_eq!(sbit_for(&[12], ColorType::Grayscale, 16), Some(vec![12])); + // A palette's entries are 8-bit whatever the index depth. + assert_eq!( + sbit_for(&[8, 8, 8], ColorType::Indexed, 1), + Some(vec![8, 8, 8]) + ); + assert_eq!(sbit_for(&[9, 8, 8], ColorType::Indexed, 8), None); + // Sub-byte grey: the count cannot exceed the depth. + assert_eq!(sbit_for(&[2], ColorType::Grayscale, 2), Some(vec![2])); + assert_eq!(sbit_for(&[3], ColorType::Grayscale, 2), None); + } + + #[test] + fn the_writers_emit_the_converted_chunk_or_none() { + // The two `write_*` entry points route through the conversions rather than emitting the + // stored bytes: a four-entry sBIT under a written palette comes out as three, and an RGB + // background under a written greyscale it cannot name comes out not at all. + let a = Ancillary { + sbit: Some(vec![5, 6, 5, 4]), + bkgd: Some(vec![0, 1, 0, 2, 0, 3]), + ..Default::default() + }; + let mut pre = vec![0u8; 8]; + a.write_pre_plte(&mut pre, DeflateEncoder::DEFAULT_EFFORT, indexed(8)); + assert_eq!(find_chunk(&pre, b"sBIT"), Some(vec![5, 6, 5])); + + let mut post = vec![0u8; 8]; + a.write_post_plte( + &mut post, + DeflateEncoder::DEFAULT_EFFORT, + header(ColorType::Grayscale, 8), + ); + assert_eq!(find_chunk(&post, b"bKGD"), None); + } } diff --git a/crates/gamut-png/src/encoder.rs b/crates/gamut-png/src/encoder.rs index 9757ca41..df0b0f03 100644 --- a/crates/gamut-png/src/encoder.rs +++ b/crates/gamut-png/src/encoder.rs @@ -8,7 +8,7 @@ use gamut_core::{ }; use gamut_deflate::{DeflateEncoder, Level}; -use crate::ancillary::{Ancillary, PhysicalUnit, SrgbIntent}; +use crate::ancillary::{Ancillary, PhysicalUnit, SrgbIntent, WrittenHeader}; use crate::backend::{IdatDeflater, IdatInfo, Registry, run_deflaters}; use crate::chunk::{self, SIGNATURE}; use crate::color::ColorType; @@ -342,8 +342,11 @@ impl PngEncoder { self.write_png( (dims.width, dims.height), sample_bytes, - ColorType::Indexed, - depth, + WrittenHeader { + color: ColorType::Indexed, + bit_depth: depth, + plte: Some(&plte), + }, |out| { chunk::write_chunk(out, *b"PLTE", &plte); if let Some(alpha) = trns { @@ -365,8 +368,7 @@ impl PngEncoder { self.write_png( (dims.width, dims.height), image.as_samples(), - color, - 8, + WrittenHeader::new(color, 8), |_| {}, out, ) @@ -391,11 +393,25 @@ impl PngEncoder { return self.write_reduced_or_native( dims, reduced, - |o| self.write_png((dims.width, dims.height), samples, color, 8, |_| {}, o), + |o| { + self.write_png( + (dims.width, dims.height), + samples, + WrittenHeader::new(color, 8), + |_| {}, + o, + ) + }, out, ); } - self.write_png((dims.width, dims.height), samples, color, 8, |_| {}, out) + self.write_png( + (dims.width, dims.height), + samples, + WrittenHeader::new(color, 8), + |_| {}, + out, + ) } /// The 16-bit twin of [`encode_alpha8`](Self::encode_alpha8). @@ -489,21 +505,30 @@ impl PngEncoder { for &sample in samples { bytes.extend_from_slice(&sample.to_be_bytes()); } - self.write_png((dims.width, dims.height), &bytes, color, 16, |_| {}, out) + self.write_png( + (dims.width, dims.height), + &bytes, + WrittenHeader::new(color, 16), + |_| {}, + out, + ) } /// Shared back end: signature → IHDR → `pre_idat` chunks (e.g. PLTE/tRNS) → filtered + /// DEFLATE-compressed scanlines as IDAT(s) → IEND. `sample_bytes` is the image in PNG storage - /// order; the stride is derived from `color` and `bit_depth`. + /// order; the stride is derived from `written`'s colour type and bit depth. `written` also + /// carries the palette `pre_idat` writes for an indexed image, which `bKGD` is resolved + /// against: the ancillary chunks whose shape is the colour type are emitted for the header + /// written here, not the one the caller set them for (see [`crate::ancillary`]). fn write_png)>( &self, (width, height): (u32, u32), sample_bytes: &[u8], - color: ColorType, - bit_depth: u8, + written: WrittenHeader<'_>, pre_idat: F, out: &mut Vec, ) -> Result { + let (color, bit_depth) = (written.color, written.bit_depth); // Stride in bytes per pixel (≥1, even for sub-byte depths) and the padded row length. let bits_per_pixel = color.channels() * bit_depth as usize; let bpp = bits_per_pixel.div_ceil(8).max(1); @@ -512,9 +537,11 @@ impl PngEncoder { let start = out.len(); out.extend_from_slice(&SIGNATURE); ihdr::write(out, width, height, bit_depth, color); - self.ancillary.write_pre_plte(out, self.effort); // colour-space chunks precede PLTE + // Colour-space chunks precede PLTE. + self.ancillary.write_pre_plte(out, self.effort, written); pre_idat(out); // PLTE + tRNS (indexed only) - self.ancillary.write_post_plte(out, self.effort); // background / physical / timing / text + // Background / physical / timing / text. + self.ancillary.write_post_plte(out, self.effort, written); let idat = self.compress_scanlines( sample_bytes, @@ -644,21 +671,34 @@ impl PngEncoder { } else { &samples }; - self.write_png(wh, sample_bytes, ColorType::Grayscale, depth, |_| {}, out) - } - Reduced::GrayAlpha8(samples) => { - self.write_png(wh, &samples, ColorType::GrayscaleAlpha, 8, |_| {}, out) - } - Reduced::Rgb8(samples) => { - self.write_png(wh, &samples, ColorType::Truecolor, 8, |_| {}, out) + self.write_png( + wh, + sample_bytes, + WrittenHeader::new(ColorType::Grayscale, depth), + |_| {}, + out, + ) } + Reduced::GrayAlpha8(samples) => self.write_png( + wh, + &samples, + WrittenHeader::new(ColorType::GrayscaleAlpha, 8), + |_| {}, + out, + ), + Reduced::Rgb8(samples) => self.write_png( + wh, + &samples, + WrittenHeader::new(ColorType::Truecolor, 8), + |_| {}, + out, + ), // §11.3.2.1: for truecolour, tRNS is three 16-bit big-endian samples naming the one // colour a decoder renders as fully transparent. At depth 8 the high byte is zero. Reduced::Rgb8Keyed { samples, key } => self.write_png( wh, &samples, - ColorType::Truecolor, - 8, + WrittenHeader::new(ColorType::Truecolor, 8), |out| { let trns = [0, key[0], 0, key[1], 0, key[2]]; chunk::write_chunk(out, *b"tRNS", &trns); @@ -669,23 +709,38 @@ impl PngEncoder { Reduced::GrayKeyed { samples, key } => self.write_png( wh, &samples, - ColorType::Grayscale, - 8, + WrittenHeader::new(ColorType::Grayscale, 8), |out| chunk::write_chunk(out, *b"tRNS", &[0, key]), out, ), - Reduced::Rgba8(samples) => { - self.write_png(wh, &samples, ColorType::TruecolorAlpha, 8, |_| {}, out) - } - Reduced::Gray16Be(bytes) => { - self.write_png(wh, &bytes, ColorType::Grayscale, 16, |_| {}, out) - } - Reduced::GrayAlpha16Be(bytes) => { - self.write_png(wh, &bytes, ColorType::GrayscaleAlpha, 16, |_| {}, out) - } - Reduced::Rgb16Be(bytes) => { - self.write_png(wh, &bytes, ColorType::Truecolor, 16, |_| {}, out) - } + Reduced::Rgba8(samples) => self.write_png( + wh, + &samples, + WrittenHeader::new(ColorType::TruecolorAlpha, 8), + |_| {}, + out, + ), + Reduced::Gray16Be(bytes) => self.write_png( + wh, + &bytes, + WrittenHeader::new(ColorType::Grayscale, 16), + |_| {}, + out, + ), + Reduced::GrayAlpha16Be(bytes) => self.write_png( + wh, + &bytes, + WrittenHeader::new(ColorType::GrayscaleAlpha, 16), + |_| {}, + out, + ), + Reduced::Rgb16Be(bytes) => self.write_png( + wh, + &bytes, + WrittenHeader::new(ColorType::Truecolor, 16), + |_| {}, + out, + ), Reduced::Indexed { depth, indices, @@ -707,8 +762,11 @@ impl PngEncoder { self.write_png( wh, sample_bytes, - ColorType::Indexed, - depth, + WrittenHeader { + color: ColorType::Indexed, + bit_depth: depth, + plte: Some(&plte), + }, |out| { chunk::write_chunk(out, *b"PLTE", &plte); if let Some(alpha) = &trns { @@ -811,8 +869,7 @@ impl EncodeImage for PngEncoder { self.write_png( (dims.width, dims.height), &packed, - ColorType::Grayscale, - 1, + WrittenHeader::new(ColorType::Grayscale, 1), |_| {}, out, ) @@ -950,32 +1007,35 @@ mod tests { /// bKGD's payload width is colour-type-specific (PNG 3rd ed. §11.3.5.1): two bytes for /// greyscale, one for indexed. Asserting the bytes rather than mere presence is what /// distinguishes the right builder from any of them. + /// + /// Each colour is one the written file can carry — a grey level inside the 8-bit depth, an + /// index inside the palette `encode_indexed8` writes — because a background the written + /// header cannot express is omitted rather than emitted for a reader to reject + /// (`ancillary::bkgd_for`), and that omission is pinned by its own tests. #[test] fn background_builders_reach_the_bkgd_chunk() { let gray = vec![0u8; 4 * 4]; let img = ImageRef::::new(&gray, Dimensions::new(4, 4).unwrap()).unwrap(); let mut png = Vec::new(); PngEncoder::new() - .with_background_gray(0x1234) + .with_background_gray(0x34) .encode_image(img, &mut png) .unwrap(); assert_eq!( find_chunk(&png, b"bKGD"), - Some(vec![0x12, 0x34]), + Some(vec![0x00, 0x34]), "greyscale bKGD is the 16-bit level, big-endian" ); // Indexed: one byte, the palette index. - let mut rgb = Vec::new(); - for i in 0..200u32 { - let c = (i % 32) as u8; - rgb.extend_from_slice(&[c, c.wrapping_add(70), 90]); - } - let img = ImageRef::::new(&rgb, Dimensions::new(200, 1).unwrap()).unwrap(); + let entries: Vec<[u8; 3]> = (0..8u8).map(|i| [i, i.wrapping_add(70), 90]).collect(); + let palette = PngPalette::new(&entries).unwrap(); + let indices: Vec = (0..200u8).map(|i| i % 8).collect(); + let img = ImageRef::::new(&indices, Dimensions::new(200, 1).unwrap()).unwrap(); let mut png = Vec::new(); PngEncoder::new() .with_background_index(7) - .encode_image(img, &mut png) + .encode_indexed8(img, &palette, &mut png) .unwrap(); assert_eq!(find_chunk(&png, b"bKGD"), Some(vec![7])); } diff --git a/crates/gamut-png/tests/ancillary_colour_type.rs b/crates/gamut-png/tests/ancillary_colour_type.rs new file mode 100644 index 00000000..f8e6b24d --- /dev/null +++ b/crates/gamut-png/tests/ancillary_colour_type.rs @@ -0,0 +1,248 @@ +//! `bKGD` and `sBIT` follow the colour type the encoder actually **writes**, not the one the +//! caller set them for (PNG §11.3.5.1, §11.3.3.4). +//! +//! Auto-reduce may write a different colour type from the input's — and since the palette and +//! colour-key candidates are *raced* against the unreduced encoding, which one lands is decided by +//! compressed size, not by anything the caller can predict when it calls `with_background_index` +//! or `with_significant_bits`. A `bKGD`/`sBIT` payload shaped for the wrong colour type is a chunk +//! libpng rejects (`pngrutil.c`, `png_handle_bKGD` / `png_handle_sBIT`: the length must match the +//! colour type, an index must be inside the palette, every value must fit the bit depth) and +//! silently drops. The encoder therefore converts each to the written header where a lossless +//! conversion exists — RGBA `sBIT` loses only its alpha entry, an RGB background becomes the index +//! of that palette entry, a grey RGB triple collapses to one grey sample — and omits the chunk +//! otherwise. +//! +//! **Technique: exact-byte over the emitted chunk stream, against libpng's own acceptance rules, +//! plus a libpng decode of every file.** The vendored oracle exposes neither `bKGD`/`sBIT` nor a +//! warning count (its warning callback discards benign errors), so libpng's acceptance of the +//! *chunk* is not observable through it today; the assertion is on the payload libpng's rules +//! accept for the written IHDR, and the decode proves the file around it is sound. + +mod common; + +use gamut_core::{Dimensions, EncodeImage, ImageRef, Rgb8, Rgba8}; +use gamut_png::PngEncoder; +use libpng_oracle::{COLOR_GRAY, COLOR_PALETTE, COLOR_RGB, COLOR_RGBA}; + +/// The payload of the first chunk of type `want`, or `None` if the file carries none. +fn read_chunk(png: &[u8], want: &[u8; 4]) -> Option> { + let mut at = 8; // signature + while at + 12 <= png.len() { + let len = u32::from_be_bytes([png[at], png[at + 1], png[at + 2], png[at + 3]]) as usize; + if &png[at + 4..at + 8] == want { + return Some(png[at + 8..at + 8 + len].to_vec()); + } + at += 12 + len; + } + None +} + +/// Auto-reduce on, everything else default: the palette and colour-key races both run. +fn encoder() -> PngEncoder { + PngEncoder::new().with_auto_reduce(true) +} + +fn encode_rgba(encoder: &PngEncoder, side: u32, samples: &[u8]) -> Vec { + let dims = Dimensions::new(side, side).expect("valid dimensions"); + let image = ImageRef::::new(samples, dims).expect("buffer matches dimensions"); + let mut out = Vec::new(); + encoder.encode_image(image, &mut out).expect("encode"); + out +} + +fn encode_rgb(encoder: &PngEncoder, side: u32, samples: &[u8]) -> Vec { + let dims = Dimensions::new(side, side).expect("valid dimensions"); + let image = ImageRef::::new(samples, dims).expect("buffer matches dimensions"); + let mut out = Vec::new(); + encoder.encode_image(image, &mut out).expect("encode"); + out +} + +/// The colour type libpng reads from the file — the one the race chose. Reading it through the +/// oracle also proves the file around the chunk under test is one libpng decodes. +fn written_colour_type(png: &[u8]) -> u8 { + libpng_oracle::decode(png).color_type +} + +/// Two opaque, non-grey colours in a checkerboard: a one-bit palette wins by a mile. +const INK: [u8; 4] = [200, 30, 60, 255]; +const PAPER: [u8; 4] = [20, 90, 220, 255]; + +fn two_colour_rgba(side: u32) -> Vec { + let mut buf = Vec::with_capacity((side * side * 4) as usize); + for y in 0..side { + for x in 0..side { + buf.extend_from_slice(if (x + y) % 2 == 0 { &INK } else { &PAPER }); + } + } + buf +} + +/// Binary alpha over one shared invisible colour, with too many visible colours for a palette: +/// the `tRNS` colour key is the only reduction on the table, and at 128 it wins (see +/// `tests/colour_key.rs`, which measured the crossover). +fn keyable_rgba(side: u32) -> Vec { + let mut buf = Vec::with_capacity((side * side * 4) as usize); + for y in 0..side { + for x in 0..side { + let cx = i64::from(x) - i64::from(side) / 2; + let cy = i64::from(y) - i64::from(side) / 2; + if cx * cx + cy * cy >= (i64::from(side) * i64::from(side)) / 9 { + buf.extend_from_slice(&[1, 2, 3, 0]); + } else { + buf.extend_from_slice(&[(x * 2) as u8, (y * 2) as u8, 200, 255]); + } + } + } + buf +} + +#[test] +fn a_palette_index_background_is_dropped_when_the_unreduced_stream_wins() { + // 64 colours at 32x32: the palette's flat PLTE+tRNS bytes are not amortised, so the unreduced + // RGBA stream wins the race (STATUS.md's cost-model table) and the caller's index has no + // palette to point into. + let src = common::corpus::palette64_rgba(32); + let png = encode_rgba(&encoder().with_background_index(0), 32, &src); + + assert_eq!( + written_colour_type(&png), + COLOR_RGBA, + "precondition: the unreduced stream won" + ); + assert_eq!( + read_chunk(&png, b"bKGD"), + None, + "a one-byte palette index under colour type 6 is a chunk libpng drops" + ); +} + +#[test] +fn rgba_significant_bits_lose_their_alpha_entry_under_a_colour_key() { + let src = keyable_rgba(128); + let png = encode_rgba(&encoder().with_significant_bits(&[8, 8, 8, 8]), 128, &src); + + assert_eq!( + written_colour_type(&png), + COLOR_RGB, + "precondition: the colour key dropped the alpha channel" + ); + assert_eq!( + read_chunk(&png, b"sBIT"), + Some(vec![8, 8, 8]), + "three entries for truecolour: the alpha entry describes a channel that is gone" + ); +} + +#[test] +fn an_rgb_background_becomes_that_entrys_index_when_the_palette_wins() { + let src = two_colour_rgba(64); + let (r, g, b) = (PAPER[0], PAPER[1], PAPER[2]); + let png = encode_rgba( + &encoder().with_background_rgb(r.into(), g.into(), b.into()), + 64, + &src, + ); + + assert_eq!( + written_colour_type(&png), + COLOR_PALETTE, + "precondition: the palette won" + ); + let plte = read_chunk(&png, b"PLTE").expect("an indexed file carries PLTE"); + let index = plte + .as_chunks::<3>() + .0 + .iter() + .position(|entry| *entry == [r, g, b]) + .expect("the background colour is a palette entry"); + assert_eq!( + read_chunk(&png, b"bKGD"), + Some(vec![index as u8]), + "one byte: the index of the entry holding the caller's colour" + ); +} + +#[test] +fn rgba_significant_bits_become_three_under_a_palette() { + let src = two_colour_rgba(64); + let png = encode_rgba(&encoder().with_significant_bits(&[8, 8, 8, 8]), 64, &src); + + assert_eq!( + written_colour_type(&png), + COLOR_PALETTE, + "precondition: the palette won" + ); + assert_eq!( + read_chunk(&png, b"sBIT"), + Some(vec![8, 8, 8]), + "an indexed sBIT is always three entries, whatever the index depth (§11.3.3.4)" + ); +} + +#[test] +fn a_grey_rgb_background_collapses_to_one_sample_under_greyscale() { + let src = common::corpus::grey_as_rgb(32); + let png = encode_rgb(&encoder().with_background_rgb(77, 77, 77), 32, &src); + + assert_eq!( + written_colour_type(&png), + COLOR_GRAY, + "precondition: the RGB input reduced to greyscale" + ); + assert_eq!( + read_chunk(&png, b"bKGD"), + Some(vec![0, 77]), + "one 16-bit big-endian grey sample" + ); +} + +#[test] +fn a_coloured_background_has_no_greyscale_form_and_is_dropped() { + let src = common::corpus::grey_as_rgb(32); + let png = encode_rgb(&encoder().with_background_rgb(1, 2, 3), 32, &src); + + assert_eq!( + written_colour_type(&png), + COLOR_GRAY, + "precondition: the RGB input reduced to greyscale" + ); + assert_eq!( + read_chunk(&png, b"bKGD"), + None, + "a background no greyscale sample can name is omitted rather than written wrong" + ); +} + +#[test] +fn chunks_set_for_the_written_colour_type_pass_through_unchanged() { + // The control: an RGBA image that stays RGBA (partial alpha, many colours) keeps its + // four-entry sBIT and six-byte bKGD byte for byte, so the conversion is inert where nothing + // changed. + let side = 16u32; + let src: Vec = (0..side * side) + .flat_map(|i| { + [ + (i * 7) as u8, + (i * 13) as u8, + (i * 29) as u8, + (i % 7 * 40) as u8, + ] + }) + .collect(); + let png = encode_rgba( + &encoder() + .with_significant_bits(&[5, 6, 5, 4]) + .with_background_rgb(1, 2, 3), + side, + &src, + ); + + assert_eq!( + written_colour_type(&png), + COLOR_RGBA, + "precondition: nothing reduced" + ); + assert_eq!(read_chunk(&png, b"sBIT"), Some(vec![5, 6, 5, 4])); + assert_eq!(read_chunk(&png, b"bKGD"), Some(vec![0, 1, 0, 2, 0, 3])); +} From 5e2807cc9d42e8270fefbe569511bdd6da56eb35 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Sat, 5 Sep 2026 22:42:02 -0400 Subject: [PATCH 45/54] fix(png): bound the filter scan's inflation by the stream that claims it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `scan_filters` budgeted the *image* the header describes against `max_image_bytes` and then handed that figure to `inflate_zlib` as the output cap. `gamut inspect` raises the budget to a gigabyte so a 16k×16k photograph is read, and at that budget a one-megabyte PNG declaring 16384×16384 RGBA8 over a zlib stream of zeros inflates to about a gigabyte before a single filter byte is read. The walk now refuses, before inflating, a stream that would inflate to more than sixty-four times its own length — but only once the image is past the decoder's default budget, so every file the decoder inflates by default is still scanned whatever its ratio (a flat 4096×4096 RGBA8 image compresses thousands-fold and is a real PNG). The floor is stated over the header, like the budget, not over the filtered length: the two differ by one filter byte per scanline, and an image exactly at the default budget must scan. The refusal is the existing `SkippedFilterScan::OverBudget`, a statement about the reader, so `is_intact` still holds for such a file. The end-to-end test discriminates by reason: without the bound the tiny stream inflates completely and the walk reports the file's `LengthMismatch`; with it, `OverBudget` and no inflation. --- crates/gamut-png/src/deconstruct.rs | 107 ++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/crates/gamut-png/src/deconstruct.rs b/crates/gamut-png/src/deconstruct.rs index e70576c6..fb708095 100644 --- a/crates/gamut-png/src/deconstruct.rs +++ b/crates/gamut-png/src/deconstruct.rs @@ -730,6 +730,31 @@ fn fits_decode_budget(header: &ihdr::Ihdr, max_image_bytes: usize) -> bool { .is_some_and(|native| native <= max_image_bytes) } +/// How many times its own length an IDAT stream may inflate, once the image it describes is past +/// the decoder's default budget. +/// +/// DEFLATE's ceiling is about 1032:1, so a stream at this ratio is either a large flat image or a +/// bomb — and above [`DEFAULT_MAX_IMAGE_BYTES`] the walk stops assuming the former. A flat 16k×16k +/// image is the one real file this declines, and it is declined as the reader's budget +/// ([`SkippedFilterScan::OverBudget`]), not as damage. +const INFLATION_RATIO: usize = 64; + +/// Whether a stream of `idat_len` compressed bytes may be inflated to `filtered_len`: it must +/// carry at least a sixty-fourth of what it claims to inflate to. Inclusive, as +/// [`fits_decode_budget`] is, and saturating — a stream too large to multiply is allowed anything, +/// not wrapped to a small allowance that would refuse every huge file. +/// +/// [`DeconstructLimits::max_image_bytes`] bounds the *image* a caller is willing to scan; this +/// bounds the *file* against it, and [`scan_filters`] applies it only past the decoder's default +/// budget, so every file the decoder inflates by default is scanned whatever its ratio. `gamut +/// inspect` raises the image budget to a gigabyte so that a 16k×16k photograph is read, and that +/// is right for a photograph — its IDAT is hundreds of megabytes. It is wrong for a megabyte +/// declaring the same header over a zlib stream of zeros, which the header budget alone would +/// inflate to that gigabyte before reading one filter byte. +fn fits_inflation_ratio(filtered_len: usize, idat_len: usize) -> bool { + filtered_len <= idat_len.saturating_mul(INFLATION_RATIO) +} + /// Inflates the IDAT stream and counts the filter byte leading each scanline. /// /// Every early return names its own reason, so a caller can tell a file this reader declined to @@ -745,6 +770,16 @@ fn scan_filters( if !fits_decode_budget(header, max_image_bytes) { return FilterScan::Skipped(SkippedFilterScan::OverBudget); } + // The image fits the caller's budget; past the decoder's *default* budget the file still has + // to be one that can plausibly inflate to it. Checked before `inflate_zlib` runs, because + // `filtered_len` is the cap it would otherwise fill from a stream of any size. The floor is + // stated over the header, like the budget, not over the filtered length: the two differ by + // one filter byte per scanline, and an image exactly at the default budget must scan. + if !fits_decode_budget(header, DEFAULT_MAX_IMAGE_BYTES) + && !fits_inflation_ratio(filtered_len, idat.len()) + { + return FilterScan::Skipped(SkippedFilterScan::OverBudget); + } let Ok(stream) = inflate::inflate_zlib(idat, filtered_len) else { return FilterScan::Skipped(SkippedFilterScan::CorruptStream); }; @@ -914,4 +949,76 @@ mod tests { fn an_overlap_is_not_fully_classified() { assert!(!report_with(&[(0, 20), (10, 33)], 33).is_fully_classified()); } + + /// A PNG whose IHDR declares `width`×`height` RGBA8 over a zlib stream of `stream_len` zero + /// bytes — a stream far too short for the header, which is the point: whether the walk + /// inflates it at all is what the reason it reports tells apart. + fn png_declaring(width: u32, height: u32, stream_len: usize) -> Vec { + let mut idat = Vec::new(); + gamut_deflate::DeflateEncoder::new().zlib_compress(&vec![0u8; stream_len], &mut idat); + let mut png = SIGNATURE.to_vec(); + ihdr::write(&mut png, width, height, 8, ColorType::TruecolorAlpha); + crate::chunk::write_chunk(&mut png, *b"IDAT", &idat); + crate::chunk::write_chunk(&mut png, *b"IEND", &[]); + png + } + + #[test] + fn a_declared_gigabyte_over_a_small_stream_is_refused_before_inflation() { + // 16384x16384 RGBA8 is exactly one gigabyte decoded, which `gamut inspect` budgets for + // (its ceiling is 1 << 30). Under the header budget alone the walk hands that gigabyte + // to `inflate_zlib` as the cap and a zlib bomb of zeros fills it from about a megabyte + // of input. The stream here is tiny, so without the ratio bound the walk inflates it + // completely and reports the *file's* `LengthMismatch`; with it, the walk reports its own + // `OverBudget` and never inflates — the reason is the discriminator. + let bomb = png_declaring(16384, 16384, 4096); + let generous = DeconstructLimits::default().with_max_image_bytes(1 << 30); + let report = deconstruct_with_limits(&bomb, generous).expect("deconstruct"); + assert_eq!( + report.filters, + FilterScan::Skipped(SkippedFilterScan::OverBudget), + "a stream that would inflate to a gigabyte from four kilobytes is the reader's \ + budget, not the file's damage" + ); + assert_eq!( + report.filtered_len, + 16384 * (16384 * 4 + 1), + "the header-derived figure is still reported" + ); + } + + #[test] + fn the_inflation_ratio_is_inclusive_and_saturates() { + // A stream may inflate to exactly sixty-four times its length and not one byte more. + assert!(fits_inflation_ratio(64 * 1000, 1000)); + assert!(!fits_inflation_ratio(64 * 1000 + 1, 1000)); + // An empty stream inflates to nothing. + assert!(fits_inflation_ratio(0, 0)); + assert!(!fits_inflation_ratio(1, 0)); + // Overflow saturates rather than wrapping to a small allowance that would refuse every + // huge stream. + assert!(fits_inflation_ratio(usize::MAX, usize::MAX / 2)); + } + + #[test] + fn an_image_inside_the_default_budget_is_scanned_whatever_its_ratio() { + // A flat image compresses thousands-fold and is a real PNG: 1024x1024 RGBA8 from a + // few dozen bytes of zlib. Its ratio is far past sixty-four, and it is inside the + // decoder's default budget, so it is inflated — and this one is sound, so it is + // counted. The floor is the header, not the filtered length: a scan refused here would + // be the walk declining a file the decoder decodes. + let side = 1024u32; + let stream_len = side as usize * (side as usize * 4 + 1); + let flat = png_declaring(side, side, stream_len); + let report = deconstruct(&flat).expect("deconstruct"); + assert!( + report.idat_compressed * INFLATION_RATIO < stream_len, + "precondition: the fixture inflates by more than the ratio allows" + ); + assert!( + report.filters.is_counted(), + "inside the default budget the ratio does not apply, got {:?}", + report.filters + ); + } } From 1851bb0b2543950dda9adf95583cd57e061dfde9 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Sat, 5 Sep 2026 22:42:22 -0400 Subject: [PATCH 46/54] fix(png): count chunks, not segments, against max_chunks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ceiling compared `segments.len()` against `DeconstructLimits::max_chunks`, and `segments` holds the signature segment too, so a file of N chunks needed `max_chunks >= N + 1` — one more than the field's own documentation says. The walk now counts the chunks materialized so far, and the boundary test in `tests/accounting.rs` pins a ten-chunk file admitted at a ceiling of ten and refused at nine, where it previously encoded the off-by-one as eleven segments. --- crates/gamut-png/src/deconstruct.rs | 5 ++++- crates/gamut-png/tests/accounting.rs | 16 +++++++++++----- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/crates/gamut-png/src/deconstruct.rs b/crates/gamut-png/src/deconstruct.rs index fb708095..eb4d6292 100644 --- a/crates/gamut-png/src/deconstruct.rs +++ b/crates/gamut-png/src/deconstruct.rs @@ -601,7 +601,10 @@ pub fn deconstruct_with_limits(png: &[u8], limits: DeconstructLimits) -> Result< } let is_iend = &chunk.chunk_type == b"IEND"; push(&mut segments, &mut tally, &chunk); - if segments.len() > limits.max_chunks { + // The signature segment is not a chunk, so the ceiling is over one fewer than + // the segments materialized so far. + let chunks_so_far = segments.len() - 1; + if chunks_so_far > limits.max_chunks { return Err(Error::invalid_input( env!("CARGO_PKG_NAME"), "PNG: more chunks than the walk's ceiling admits", diff --git a/crates/gamut-png/tests/accounting.rs b/crates/gamut-png/tests/accounting.rs index fa098bf0..9bf7511e 100644 --- a/crates/gamut-png/tests/accounting.rs +++ b/crates/gamut-png/tests/accounting.rs @@ -557,8 +557,10 @@ fn the_chunk_ceiling_admits_exactly_its_own_count_and_refuses_one_more() { // The chunk count is chosen by the input -- a chunk costs 12 bytes and buys a segment -- so // the walk caps it. Asserted *at the boundary* rather than far past it: a file well over the // ceiling is refused by `>`, `>=` and `==` alike, so only the exact count separates them. - // Eleven segments here: the signature, IHDR, eight fillers and IEND. - const SEGMENTS: usize = 11; + // Ten chunks here — IHDR, eight fillers and IEND — under eleven segments, because the + // signature is a segment but not a chunk: `max_chunks` counts what its name says, so a + // ceiling of ten admits this file and a ceiling of nine refuses it. + const CHUNKS: usize = 10; let mut chunks = vec![common::chunk(b"IHDR", &common::ihdr_payload(1, 1, 8, 0, 0))]; for _ in 0..8 { chunks.push(common::chunk(b"crUD", &[])); @@ -566,13 +568,17 @@ fn the_chunk_ceiling_admits_exactly_its_own_count_and_refuses_one_more() { chunks.push(common::chunk(b"IEND", &[])); let png = common::png_from_chunks(&chunks); - let exact = DeconstructLimits::default().with_max_chunks(SEGMENTS); + let exact = DeconstructLimits::default().with_max_chunks(CHUNKS); let report = deconstruct_with_limits(&png, exact) .expect("a file of exactly the ceiling's size is admitted, not refused"); - assert_eq!(report.segments.len(), SEGMENTS); + assert_eq!( + report.segments.len(), + CHUNKS + 1, + "the signature segment is not a chunk" + ); assert!(report.is_fully_classified(), "and it reports normally"); - let one_short = DeconstructLimits::default().with_max_chunks(SEGMENTS - 1); + let one_short = DeconstructLimits::default().with_max_chunks(CHUNKS - 1); let err = deconstruct_with_limits(&png, one_short) .expect_err("one past the ceiling the walk refuses rather than allocating"); assert!( From 90ff376dc1fc88dafd60dd9c56942fcd80c2855c Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Sat, 5 Sep 2026 22:44:08 -0400 Subject: [PATCH 47/54] test(png): pin the chunk tally's constant-time lookup structurally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `the_chunk_tally_does_not_slow_down_when_every_type_is_distinct` asserted a wall-clock ratio between two `deconstruct` runs inside the blocking test gate. Timing is what the gate must not depend on: under `llvm-cov` instrumentation and parallel test binaries a 20x ratio is a property of the machine's load, not of the code. The algorithmic claim — a type is found through the tally's index, never by scanning the stats — is now asserted structurally where the index is visible, inline in `deconstruct.rs`: after a mixed sequence of records the index holds exactly one entry per distinct type, each at the position of its stats entry, in first-appearance order, with the counts both arms of `record` produce. The public-side test keeps its content assertions at scale (262 144 distinct types against the same bytes with one type), drops the two `Instant` measurements, and is renamed for what it now pins. Timing belongs to `benches/`. --- crates/gamut-png/src/deconstruct.rs | 38 ++++++++++++++++++++++++++++ crates/gamut-png/tests/accounting.rs | 32 ++++++++--------------- 2 files changed, 48 insertions(+), 22 deletions(-) diff --git a/crates/gamut-png/src/deconstruct.rs b/crates/gamut-png/src/deconstruct.rs index eb4d6292..e827585d 100644 --- a/crates/gamut-png/src/deconstruct.rs +++ b/crates/gamut-png/src/deconstruct.rs @@ -990,6 +990,44 @@ mod tests { ); } + /// The tally answers "have I seen this type?" from its index, never by scanning `stats` — + /// which is what makes a file of N distinct chunk types cost O(N) rather than O(N²). That is + /// a structural claim, so it is asserted structurally: after any sequence of records, the + /// index holds exactly one entry per distinct type, and each maps to the position in `stats` + /// whose entry carries that type. A `record` that failed to index a new type, or indexed it at + /// the wrong position, would fall back to nothing at all — the lookup below has no linear + /// scan to fall back to — and this is where that shows. Timing belongs to `benches/`. + #[test] + fn the_tally_index_names_every_recorded_type_at_its_position() { + let mut tally = ChunkTally::new(); + let types: Vec<[u8; 4]> = (0..300u32).map(|i| i.to_be_bytes()).collect(); + for (i, ty) in types.iter().enumerate() { + // Every type once, every third one a second time: both arms of `record`. + tally.record(*ty, i); + if i % 3 == 0 { + tally.record(*ty, 1); + } + } + assert_eq!( + tally.index.len(), + tally.stats.len(), + "one index entry per distinct type" + ); + assert_eq!(tally.stats.len(), types.len()); + for (at, stats) in tally.stats.iter().enumerate() { + assert_eq!( + tally.index.get(&stats.chunk_type), + Some(&at), + "type {:?} is indexed at its own position", + stats.chunk_type + ); + assert_eq!(stats.chunk_type, types[at], "first-appearance order"); + let repeated = at % 3 == 0; + assert_eq!(stats.count, if repeated { 2 } else { 1 }); + assert_eq!(stats.payload_bytes, at + usize::from(repeated)); + } + } + #[test] fn the_inflation_ratio_is_inclusive_and_saturates() { // A stream may inflate to exactly sixty-four times its length and not one byte more. diff --git a/crates/gamut-png/tests/accounting.rs b/crates/gamut-png/tests/accounting.rs index 9bf7511e..3601e37d 100644 --- a/crates/gamut-png/tests/accounting.rs +++ b/crates/gamut-png/tests/accounting.rs @@ -9,8 +9,6 @@ mod common; -use std::time::Instant; - use gamut_core::{Dimensions, EncodeImage, ImageRef, Rgb8, Rgba8}; use gamut_png::{ ChunkStats, DeconstructLimits, FilterScan, FilterStrategy, FilterType, PngEncoder, Segment, @@ -185,22 +183,22 @@ fn synthetic_type(i: usize) -> [u8; 4] { ] } -/// Deconstruction must not slow down when every chunk type in the file is distinct. +/// A file whose every chunk type is distinct is tallied one entry per type, in order — at a size +/// where the quadratic walk this replaced would not finish inside a test. /// /// A chunk type is four unvalidated bytes and the walk never drops a chunk, so an attacker /// chooses how many *distinct* types a file carries — one per 12-byte chunk, if they like. /// Accumulating the per-type totals with a linear scan made this quadratic in the file length /// (measured: 4.8 MB → 40.9 s), reachable from `gamut inspect` on an untrusted file. /// -/// The claim asserted is not "fast" — an absolute wall-clock ceiling is flaky under `llvm-cov` -/// and parallel test binaries — but "the cost does not depend on how many distinct types the file -/// carries". The two halves are byte-for-byte the same length and carry the same number of -/// chunks, differing only in how many types those chunks use, and they run back to back in one -/// process under one load, so each calibrates the other. The fixed path measures ~2–4×; the -/// defect is three orders of magnitude worse, leaving ~5× of headroom above the fix and ~50× -/// below the defect. The structural assertions below mean it is not purely a timing test. +/// The complexity claim itself is pinned structurally, inside the crate, where the tally's index +/// is visible (`deconstruct::tests::the_tally_index_names_every_recorded_type_at_its_position`): +/// a wall-clock ratio between two runs in the blocking gate is flaky under `llvm-cov` and parallel +/// test binaries, and timing belongs to `benches/`. What this test adds from the public side is +/// the *content* at scale — 262 144 distinct types against the same bytes with one type — which +/// is what the index exists to produce, and a fixture that would not complete under the defect. #[test] -fn the_chunk_tally_does_not_slow_down_when_every_type_is_distinct() { +fn every_distinct_chunk_type_gets_its_own_tally_entry_at_scale() { /// Empty chunks between IHDR and IEND: 12 bytes each, so ~3.1 MB per half. const CHUNKS: usize = 262_144; @@ -218,15 +216,11 @@ fn the_chunk_tally_does_not_slow_down_when_every_type_is_distinct() { assert_eq!( repeated.len(), distinct.len(), - "the two halves must be the same length, or the ratio compares two workloads" + "the two halves are the same bytes apart from the types they use" ); - let started = Instant::now(); let repeated_report = deconstruct(&repeated).expect("deconstruct"); - let repeated_elapsed = started.elapsed(); - let started = Instant::now(); let distinct_report = deconstruct(&distinct).expect("deconstruct"); - let distinct_elapsed = started.elapsed(); assert_eq!( distinct_report.chunks.len(), @@ -243,12 +237,6 @@ fn the_chunk_tally_does_not_slow_down_when_every_type_is_distinct() { "IHDR, the one repeated type, IEND" ); assert_eq!(repeated_report.chunks[1].count, CHUNKS); - - assert!( - distinct_elapsed < 20 * repeated_elapsed, - "distinct types cost {distinct_elapsed:?} against {repeated_elapsed:?} for the same \ - bytes with one type: the tally is scaling with the number of distinct types" - ); } #[test] From 45176794e799a0eea50efccffa805fb982898e13 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Sat, 5 Sep 2026 22:45:46 -0400 Subject: [PATCH 48/54] docs(png): record what the races cost and how the chunks follow them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `decoder.rs`: the fixture builder's note said the encoder cannot write greyscale/truecolour tRNS colour keys; it can since the colour-key reduction landed. It cannot write interlaced files, which is the reason the fixture is hand-built, and the hand-built key keeps the decoder's claim independent of `reduce`'s. - `STATUS.md`: the worst-case pass count of the nested races — 7 brute-force strategies × the palette/colour-key race × the cleanup race = 28 filter-plus-DEFLATE passes for one file — recorded against the 7 of `BruteForce` alone, with the cost-model remainder pointed at #480; the transparent cleanup named as the crate's one lossy knob; and the `bKGD`/`sBIT` resolution against the written header, cross-referenced from the metadata axis. - `deconstruct.rs`: `DeconstructLimits::max_image_bytes` and `SkippedFilterScan::OverBudget` say that a budget past the decoder's default admits larger images, not larger inflations from small files. --- crates/gamut-png/STATUS.md | 20 ++++++++++++++++++-- crates/gamut-png/src/decoder.rs | 5 +++-- crates/gamut-png/src/deconstruct.rs | 12 ++++++++++-- 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/crates/gamut-png/STATUS.md b/crates/gamut-png/STATUS.md index e463b63a..9ae4883d 100644 --- a/crates/gamut-png/STATUS.md +++ b/crates/gamut-png/STATUS.md @@ -135,8 +135,8 @@ byte) plus removing a sixth redundant filter pass per scanline. | 2 | DEFLATE quality | **good, ~2% behind zopfli**, and honestly documented in `gamut-deflate`. Two contained wins remain: an 8-byte-at-a-time match compare, and `parse_dp`'s single-distance relaxation. [#478], [#479] | | 3 | Smallest lawful representation | **done** — grey, alpha-drop, ≤256 palette, 16→8, sub-byte, and a `tRNS` colour key for grey/truecolour. The key is worth ~7–9% on a contiguous transparent region, *not* the 25% the raw-byte arithmetic suggests: the alpha plane it removes is usually the most compressible plane in the image. | | 4 | Palette optimization | **partial** — trailing-opaque `tRNS` trim, plus ordering: transparent entries first (so that trim cuts as far as §11.3.2.1 allows) then by luma. Worth −14.7% on the sprite row against +1.5% on `palette64`. Modified-Zeng ordering and caller-supplied palette cleanup remain. [#482] | -| 5 | Cleaning invisible data | **done** — `with_transparent_cleanup`, opt-in, on every alpha-carrying layout at 8 and 16 bits. Worth **40.1%** on the sprite row, and it is what makes a colour key reachable at all on a source whose invisible pixels carry different unseen colours. It is a *transform*, not a reduction, so it is **raced** rather than assumed: on `palette64_rgba8` cleaning measured −2.3% at 32×32, **+10.7% at 128×128** and −5.2% at 256×256, because zeroing invisible pixels that carry structure destroys bytes DEFLATE was compressing. `cleaned_or_plain` encodes both and keeps the smaller, so the knob can never cost bytes. | -| 6 | Metadata hygiene | **no policy** — the encoder emits exactly what the caller set, and `gamut convert` drops metadata on the PNG path. [#483] | +| 5 | Cleaning invisible data | **done** — `with_transparent_cleanup`, opt-in, on every alpha-carrying layout at 8 and 16 bits. It is the crate's **one lossy knob**: it rewrites stored samples no decoder renders, where every other reduction here is byte-exact, which is why it is off by default and separate from `with_auto_reduce`. Worth **40.1%** on the sprite row, and it is what makes a colour key reachable at all on a source whose invisible pixels carry different unseen colours. It is a *transform*, not a reduction, so it is **raced** rather than assumed: on `palette64_rgba8` cleaning measured −2.3% at 32×32, **+10.7% at 128×128** and −5.2% at 256×256, because zeroing invisible pixels that carry structure destroys bytes DEFLATE was compressing. `cleaned_or_plain` encodes both and keeps the smaller, so the knob can never cost bytes. | +| 6 | Metadata hygiene | **no policy** — the encoder emits exactly what the caller set, and `gamut convert` drops metadata on the PNG path. The one exception is shape, not policy: `bKGD` and `sBIT` are resolved against the header actually written (see [Chunks that follow the race](#the-cost-model-and-why-it-is-a-race)). [#483] | | 7 | Interlacing | **correctly none.** Adam7 costs 5–20%; out of scope by declaration. | | 8 | Effort / speed / determinism | Output is byte-reproducible (no time, no randomness, and the one `HashMap` is never iterated). Three independent knobs, no composed dial. No parallelism. [#484] | | 9 | Correctness / robustness | **covered** — 16-bit, odd dimensions, 1×1, CRC policy, malformed input. | @@ -168,6 +168,22 @@ each would have carried a palette and been larger. Only palette reductions pay f encode; greyscale, alpha-drop and 16→8 demotion add no chunks, so for them the raw comparison is sound. +**What the races cost.** Each race is a full extra encode, and they nest: `FilterStrategy::BruteForce` +tries seven whole-image strategies, `write_reduced_or_native` encodes both candidates when the +reduction carries a chunk (a palette's `PLTE`/`tRNS`, a colour key's `tRNS`), and `cleaned_or_plain` +encodes both the cleaned and the untouched samples when cleanup changed anything. The worst case — +`Level::Best` + `BruteForce` + auto-reduce + cleanup on an alpha image that is both cleanable and +palettisable or keyable — is therefore 7 × 2 × 2 = **28** filter-plus-DEFLATE passes for one file, +against 7 for `BruteForce` alone. That is the price of choosing by measured size rather than by a +cost model; a model good enough to skip the losing candidate is [#480]'s remainder. + +**Chunks that follow the race.** `bKGD` and `sBIT` have a payload whose shape is the colour type, and +the race decides the colour type after they were set. Both are resolved against the header actually +written — RGBA `sBIT` loses its alpha entry under RGB or a palette, an RGB or grey background under a +palette becomes the index of its entry, a grey RGB triple collapses to one grey sample — and omitted +where no lossless conversion exists, since a payload shaped for the wrong colour type is a chunk +libpng rejects and drops. + [#437]: https://github.com/visualcommons/gamut/issues/437 [#478]: https://github.com/visualcommons/gamut/issues/478 [#479]: https://github.com/visualcommons/gamut/issues/479 diff --git a/crates/gamut-png/src/decoder.rs b/crates/gamut-png/src/decoder.rs index 85547f4e..a1a42942 100644 --- a/crates/gamut-png/src/decoder.rs +++ b/crates/gamut-png/src/decoder.rs @@ -1326,8 +1326,9 @@ mod tests { assert_eq!(decoded.as_samples(), expected); } - /// Hand-assembles a greyscale PNG from raw parts (the encoder cannot write interlaced files - /// or greyscale/truecolour tRNS colour keys). + /// Hand-assembles a greyscale PNG from raw parts (the encoder cannot write interlaced files, + /// and choosing the colour key by hand keeps the decoder's claim independent of + /// `reduce`'s). fn build_gray_png( width: u32, height: u32, diff --git a/crates/gamut-png/src/deconstruct.rs b/crates/gamut-png/src/deconstruct.rs index e827585d..1ab9ca4c 100644 --- a/crates/gamut-png/src/deconstruct.rs +++ b/crates/gamut-png/src/deconstruct.rs @@ -215,8 +215,11 @@ impl FilterScan { #[non_exhaustive] pub enum SkippedFilterScan { /// The image the header describes is larger than this reader's byte budget, so the walk - /// declined to inflate a stream a decode would refuse to allocate. **Nothing is known to be - /// wrong with the file** — it may be a perfectly sound very large PNG. + /// declined to inflate a stream a decode would refuse to allocate — or the image is past the + /// decoder's default budget and the stream is too short to plausibly inflate to it (more than + /// sixty-four times its own length), which is the shape of a zlib bomb under a permissive + /// budget. **Nothing is known to be wrong with the file** — it may be a perfectly sound very + /// large PNG. OverBudget = 0, /// The IDAT stream is not a valid zlib stream, is truncated, or inflates past the length the /// header implies. @@ -497,6 +500,11 @@ pub struct DeconstructLimits { /// This is the quantity [`crate::PngDecoder::with_max_image_bytes`] budgets, and matching the /// two is the point: a report is only "what a decode would have allocated" against a decoder /// configured the same way. The default matches the decoder's default. + /// + /// Raising it past the decoder's default admits larger *images*, not larger *inflations from + /// small files*: above that default the walk also refuses, before inflating, a stream that + /// would grow to more than sixty-four times its own length, so a permissive budget cannot be + /// spent by a zlib bomb. That refusal is the same [`SkippedFilterScan::OverBudget`]. pub max_image_bytes: usize, /// The largest number of chunks the walk will materialize into segments and per-type stats. /// From 2c084802cfecf014e537e2dda4a7af40426b8039 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Sat, 5 Sep 2026 22:45:46 -0400 Subject: [PATCH 49/54] docs(cli): state why inspect's verification gate is PNG-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The module doc said the TIFF/DNG and PNG gates are "deliberately the same strength" without saying where they differ: a TIFF or DNG walk reads directories and never pixel data, so nothing in it can be declined and its verdict never depends on the reader's budget, while a PNG's verification is an inflation that can be. PNG alone therefore has a third outcome — not damaged, not verified — and exits non-zero for it distinctly. The doc now says so, records why gating on `is_intact` would make the formats symmetric in wording and asymmetric in strength, and notes that the gigabyte budget bounds the image rather than what a small file may inflate to. --- crates/gamut-cli/src/commands/inspect.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/gamut-cli/src/commands/inspect.rs b/crates/gamut-cli/src/commands/inspect.rs index dbb0bc7a..ebd776e8 100644 --- a/crates/gamut-cli/src/commands/inspect.rs +++ b/crates/gamut-cli/src/commands/inspect.rs @@ -29,7 +29,18 @@ //! of the file. Such a file exits non-zero saying it was not verified, distinctly from a damaged //! one. To keep that rare, the walk's budget here is a gigabyte rather than the decoder's 64 MiB, //! which is past any real image — at the decoder's budget every PNG over 4096x4096 RGBA8 would go -//! unread. +//! unread. That gigabyte bounds the *image*, not what a small file may inflate to: past the +//! decoder's default budget the walk also refuses, before inflating, a stream that would grow to +//! more than sixty-four times its own length, so a megabyte declaring a 16k×16k header over a zlib +//! stream of zeros is reported as not verified (over budget), never inflated to a gigabyte. +//! +//! The gate is therefore **asymmetric across formats, and deliberately so**. A TIFF or DNG walk +//! reads directories and tags, never pixel data, so there is no step in it this reader can decline +//! and `is_fully_accounted()` never depends on the reader's budget. A PNG's verification step *is* +//! an inflation, and inflation can be declined; so PNG alone has a third outcome — not damaged, +//! not verified — and exits non-zero for it with its own message, distinct from a damaged file's. +//! Gating PNG on `is_intact()` instead would make the two formats symmetric in wording and +//! asymmetric in strength: a TIFF's exit 0 means the walk read everything, and a PNG's would not. //! //! For PNG the same walk answers a second question: **where did the bytes go?** The report carries //! the per-chunk-type breakdown, the compressed IDAT total against the filtered stream it inflates From eabb0bd1d04db92e1f15dcd2a40ecb1e77fd1b65 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Sat, 5 Sep 2026 22:46:26 -0400 Subject: [PATCH 50/54] docs(png)!: record FilterStrategy as non-exhaustive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This branch marked `FilterStrategy` `#[non_exhaustive]` so that a heuristic — a measurement result — can be added as the corpus grows. That is a breaking change for any downstream exhaustive `match`, and the commit that made it did not say so; `STATUS.md` now records it on the filter-selection axis, and this message carries the marker the release tooling reads. BREAKING CHANGE: FilterStrategy is #[non_exhaustive]; downstream exhaustive matches must add a wildcard arm --- crates/gamut-png/STATUS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/gamut-png/STATUS.md b/crates/gamut-png/STATUS.md index 9ae4883d..80b0af94 100644 --- a/crates/gamut-png/STATUS.md +++ b/crates/gamut-png/STATUS.md @@ -131,7 +131,7 @@ byte) plus removing a sixth redundant filter pass per scanline. | # | Axis | State | | --- | --- | --- | -| 1 | Filter selection | **partial** — MinSumAbs, Entropy and Bigrams per line, plus seven whole-image candidates each fully DEFLATEd. Bigrams is worth 22–32% where it wins (see above). Still missing: per-line trial deflate, `AtomicMin` pruning, and a two-tier cheap-trial codec. [#480] | +| 1 | Filter selection | **partial** — MinSumAbs, Entropy and Bigrams per line, plus seven whole-image candidates each fully DEFLATEd. Bigrams is worth 22–32% where it wins (see above). Still missing: per-line trial deflate, `AtomicMin` pruning, and a two-tier cheap-trial codec. [#480]. `FilterStrategy` became `#[non_exhaustive]` with this phase — a heuristic is a measurement result and the set grows with the corpus — which is a **breaking change** for any downstream exhaustive `match`: add a wildcard arm. | | 2 | DEFLATE quality | **good, ~2% behind zopfli**, and honestly documented in `gamut-deflate`. Two contained wins remain: an 8-byte-at-a-time match compare, and `parse_dp`'s single-distance relaxation. [#478], [#479] | | 3 | Smallest lawful representation | **done** — grey, alpha-drop, ≤256 palette, 16→8, sub-byte, and a `tRNS` colour key for grey/truecolour. The key is worth ~7–9% on a contiguous transparent region, *not* the 25% the raw-byte arithmetic suggests: the alpha plane it removes is usually the most compressible plane in the image. | | 4 | Palette optimization | **partial** — trailing-opaque `tRNS` trim, plus ordering: transparent entries first (so that trim cuts as far as §11.3.2.1 allows) then by luma. Worth −14.7% on the sprite row against +1.5% on `palette64`. Modified-Zeng ordering and caller-supplied palette cleanup remain. [#482] | From 5f8e71b6423957397557402f3e712681e01703b5 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Sat, 5 Sep 2026 23:51:53 -0400 Subject: [PATCH 51/54] test(png): a grey sBIT needs all three channels to agree, not two MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The in-diff mutation run left one survivor: `&&` → `||` in `sbit_for`'s grey test. The negative case pinned a triple where no adjacent pair agrees, which both operators reject alike; a triple with exactly one agreeing pair separates them, so two are added — one under `Grayscale`, one under `GrayscaleAlpha`. --- crates/gamut-png/src/ancillary.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/gamut-png/src/ancillary.rs b/crates/gamut-png/src/ancillary.rs index 1236dc59..58578552 100644 --- a/crates/gamut-png/src/ancillary.rs +++ b/crates/gamut-png/src/ancillary.rs @@ -595,6 +595,9 @@ mod tests { // Grey to RGB where the channels agree, and never to a differing RGB. assert_eq!(sbit_for(&[7], ColorType::Truecolor, 8), Some(vec![7, 7, 7])); assert_eq!(sbit_for(&[5, 6, 5], ColorType::Grayscale, 8), None); + // All three must agree, not any two: one agreeing pair is still not a grey. + assert_eq!(sbit_for(&[5, 5, 6], ColorType::Grayscale, 8), None); + assert_eq!(sbit_for(&[6, 5, 5], ColorType::GrayscaleAlpha, 8), None); // An alpha entry cannot be invented. assert_eq!(sbit_for(&[5, 6, 5], ColorType::TruecolorAlpha, 8), None); assert_eq!(sbit_for(&[7], ColorType::GrayscaleAlpha, 8), None); From 589df4f8d8f8c5930ef18893a46a94246beb2104 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Sun, 6 Sep 2026 00:41:21 -0400 Subject: [PATCH 52/54] fix(png): resolve a background against the written palette's alpha and origin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two re-review findings on `bkgd_for`'s palette arm. An RGB or grey background was mapped to the *first* PLTE entry holding its triple. The encoder orders transparent entries first, and transparent cleanup zeroes every invisible pixel to (0, 0, 0, 0), so an image with opaque black carries two [0, 0, 0] entries with the transparent one ahead — and a black background named the entry a viewer never sees. `WrittenPalette` now carries the `tRNS` payload beside `PLTE`, and `index_of` prefers an entry with alpha 255, falling back to the first match only when no opaque twin exists (its RGB is still what a compositing reader paints). A caller's `with_background_index` was kept whenever the written palette held that many entries. Under auto-reduce the palette is the encoder's, in an order the caller never saw, so the index named an arbitrary entry. `WrittenPalette` now records its `PaletteOrigin`: an index is kept only on the `encode_indexed8` path, whose palette is the caller's, and omitted under a derived palette. Both pinned end to end in `tests/ancillary_colour_type.rs` — the black-on- transparent sprite reproduces the first (index 0, the transparent twin, before this change) and the two-colour checkerboard the second — and by unit tests on `bkgd_for`. --- crates/gamut-png/src/ancillary.rs | 164 ++++++++++++++---- crates/gamut-png/src/encoder.rs | 16 +- .../gamut-png/tests/ancillary_colour_type.rs | 105 +++++++++++ 3 files changed, 250 insertions(+), 35 deletions(-) diff --git a/crates/gamut-png/src/ancillary.rs b/crates/gamut-png/src/ancillary.rs index 58578552..19547b45 100644 --- a/crates/gamut-png/src/ancillary.rs +++ b/crates/gamut-png/src/ancillary.rs @@ -218,16 +218,77 @@ impl Ancillary { } } -/// The IHDR — and, for an indexed image, the `PLTE` payload — the ancillary chunks are written -/// under: what a colour-type-shaped payload has to agree with. +/// Whose palette an indexed image is written with — which decides what a caller's palette +/// *index* refers to. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PaletteOrigin { + /// The caller's own palette (`encode_indexed8`): an index the caller set names one of its + /// entries. + Caller, + /// A palette the encoder derived from the pixels under auto-reduce, in an order the caller + /// never saw (transparent entries first, then by luma): an index the caller set names nothing + /// in it. + Derived, +} + +/// The palette an indexed image is written with. +#[derive(Debug, Clone, Copy)] +pub(crate) struct WrittenPalette<'a> { + /// The `PLTE` payload: RGB triples. + pub plte: &'a [u8], + /// The `tRNS` payload — one alpha per leading entry, entries past its end being opaque + /// (§11.3.2.1) — or `None` when every entry is opaque. + pub trns: Option<&'a [u8]>, + /// Whose palette it is. + pub origin: PaletteOrigin, +} + +impl WrittenPalette<'_> { + /// The number of entries. + fn len(self) -> usize { + self.plte.len() / 3 + } + + /// Entry `index`'s alpha: its `tRNS` byte, or 255 past the end of `tRNS`. + fn alpha(self, index: usize) -> u8 { + self.trns + .and_then(|trns| trns.get(index).copied()) + .unwrap_or(255) + } + + /// The index of the entry holding `rgb`, preferring an opaque one. + /// + /// A background is a colour a viewer sees, so where a triple appears both as an opaque entry + /// and as a transparent one — which the encoder's transparent-first ordering puts *first*, + /// and which transparent cleanup manufactures whenever the image has opaque black — the + /// opaque entry is the one meant. A triple that appears only under transparency still names + /// that entry: its RGB is what a compositing reader paints. + fn index_of(self, rgb: [u8; 3]) -> Option { + let matches = || { + self.plte + .as_chunks::<3>() + .0 + .iter() + .enumerate() + .filter(move |(_, entry)| **entry == rgb) + .map(|(index, _)| index) + }; + matches() + .find(|&index| self.alpha(index) == 255) + .or_else(|| matches().next()) + } +} + +/// The IHDR — and, for an indexed image, the palette — the ancillary chunks are written under: +/// what a colour-type-shaped payload has to agree with. #[derive(Debug, Clone, Copy)] pub(crate) struct WrittenHeader<'a> { /// The colour type IHDR declares. pub color: ColorType, /// The bit depth IHDR declares. pub bit_depth: u8, - /// The `PLTE` payload (RGB triples) for [`ColorType::Indexed`]; `None` otherwise. - pub plte: Option<&'a [u8]>, + /// The palette for [`ColorType::Indexed`]; `None` otherwise. + pub palette: Option>, } impl WrittenHeader<'static> { @@ -236,7 +297,7 @@ impl WrittenHeader<'static> { Self { color, bit_depth, - plte: None, + palette: None, } } } @@ -250,10 +311,13 @@ impl WrittenHeader<'static> { /// - a grey sample and an RGB triple whose channels agree are the same colour, either way round; /// - an RGB or grey colour under a palette becomes the index of the entry holding it — which /// exists whenever the background colour occurs in the image, since the palette is built from -/// the image — and is omitted when no entry does; -/// - a palette index names a colour only inside a palette. Under a written palette it is kept -/// when it is in range; under any other colour type there is no palette it refers to (the one -/// caller-supplied palette path, `encode_indexed8`, always writes indexed), so it is omitted; +/// the image — preferring an opaque entry over a transparent twin of the same triple +/// ([`WrittenPalette::index_of`]), and is omitted when no entry does; +/// - a palette index names a colour only inside the palette the caller supplied. It is kept, +/// when in range, on the `encode_indexed8` path, whose palette is the caller's; under an +/// encoder-derived palette ([`PaletteOrigin::Derived`]) it names an entry in an order the +/// caller never saw, and under any other colour type there is no palette at all, so in both +/// cases it is omitted; /// - a grey or RGB sample must fit the written depth (`value < 1 << depth` below 16 bits); one /// that does not is omitted rather than written as a chunk the reader rejects. /// @@ -264,9 +328,12 @@ pub(crate) fn bkgd_for(bkgd: &[u8], written: WrittenHeader<'_>) -> Option { - let entries = written.plte.map_or(0, |plte| plte.len() / 3); - return (written.color == ColorType::Indexed && usize::from(index) < entries) - .then(|| vec![index]); + // An index names an entry only in the palette the caller supplied. + let palette = written.palette?; + return (written.color == ColorType::Indexed + && palette.origin == PaletteOrigin::Caller + && usize::from(index) < palette.len()) + .then(|| vec![index]); } [hi, lo] => [sample(hi, lo); 3], [r1, r0, g1, g0, b1, b0] => [sample(r1, r0), sample(g1, g0), sample(b1, b0)], @@ -276,12 +343,7 @@ pub(crate) fn bkgd_for(bkgd: &[u8], written: WrittenHeader<'_>) -> Option { let entry = rgb.map(|v| u8::try_from(v).ok()); let entry = [entry[0]?, entry[1]?, entry[2]?]; - let index = written - .plte? - .as_chunks::<3>() - .0 - .iter() - .position(|e| *e == entry)?; + let index = written.palette?.index_of(entry)?; u8::try_from(index).ok().map(|index| vec![index]) } ColorType::Grayscale | ColorType::GrayscaleAlpha => { @@ -380,11 +442,7 @@ mod tests { use super::*; /// The header the pre-existing serialisation tests were written against: 8-bit truecolour. - const RGB8: WrittenHeader<'static> = WrittenHeader { - color: ColorType::Truecolor, - bit_depth: 8, - plte: None, - }; + const RGB8: WrittenHeader<'static> = WrittenHeader::new(ColorType::Truecolor, 8); fn find_chunk(png: &[u8], ty: &[u8; 4]) -> Option> { // Walk the chunk stream (after the 8-byte signature) and return a chunk's data. @@ -490,33 +548,75 @@ mod tests { } fn header(color: ColorType, bit_depth: u8) -> WrittenHeader<'static> { - WrittenHeader { - color, - bit_depth, - plte: None, - } + WrittenHeader::new(color, bit_depth) } /// Three entries: red, a grey, blue. const PLTE: [u8; 9] = [200, 30, 60, 77, 77, 77, 20, 90, 220]; - fn indexed(bit_depth: u8) -> WrittenHeader<'static> { + fn palette(origin: PaletteOrigin, trns: Option<&'static [u8]>) -> WrittenHeader<'static> { WrittenHeader { color: ColorType::Indexed, + bit_depth: 8, + palette: Some(WrittenPalette { + plte: &PLTE, + trns, + origin, + }), + } + } + + /// The caller's own opaque palette, at index depth 8. + fn indexed(bit_depth: u8) -> WrittenHeader<'static> { + WrittenHeader { bit_depth, - plte: Some(&PLTE), + ..palette(PaletteOrigin::Caller, None) } } #[test] - fn a_background_index_survives_only_inside_a_palette_that_holds_it() { + fn a_background_index_survives_only_inside_the_callers_palette() { assert_eq!(bkgd_for(&[2], indexed(2)), Some(vec![2])); assert_eq!(bkgd_for(&[3], indexed(2)), None, "past the palette"); - // The caller's index refers to no palette the file carries. + // An encoder-derived palette is in an order the caller never saw. + assert_eq!(bkgd_for(&[2], palette(PaletteOrigin::Derived, None)), None); + // And under any other colour type there is no palette at all. assert_eq!(bkgd_for(&[0], header(ColorType::TruecolorAlpha, 8)), None); assert_eq!(bkgd_for(&[0], header(ColorType::Grayscale, 8)), None); } + #[test] + fn a_colour_with_a_transparent_twin_names_the_opaque_entry() { + // Two black entries: the transparent one first, as the encoder orders them. + const BLACKS: [u8; 9] = [0, 0, 0, 0, 0, 0, 20, 90, 220]; + let twins = |trns: Option<&'static [u8]>| WrittenHeader { + color: ColorType::Indexed, + bit_depth: 8, + palette: Some(WrittenPalette { + plte: &BLACKS, + trns, + origin: PaletteOrigin::Derived, + }), + }; + assert_eq!( + bkgd_for(&[0, 0, 0, 0, 0, 0], twins(Some(&[0]))), + Some(vec![1]) + ); + // Past the end of tRNS every entry is opaque, so the first match is opaque and wins. + assert_eq!(bkgd_for(&[0, 0, 0, 0, 0, 0], twins(None)), Some(vec![0])); + // A triple that exists only under transparency still names that entry: its RGB is what a + // compositing reader paints. + assert_eq!( + bkgd_for(&[0, 0, 0, 0, 0, 0], twins(Some(&[0, 0]))), + Some(vec![0]) + ); + // Derivation is independent of the origin: an RGB colour resolves against either. + assert_eq!( + bkgd_for(&[0, 20, 0, 90, 0, 220], twins(Some(&[0]))), + Some(vec![2]) + ); + } + #[test] fn a_colour_under_a_palette_becomes_the_index_of_its_entry() { // RGB (20, 90, 220) is entry 2; grey 77 is entry 1; (1, 2, 3) is nowhere. diff --git a/crates/gamut-png/src/encoder.rs b/crates/gamut-png/src/encoder.rs index df0b0f03..1e7477e5 100644 --- a/crates/gamut-png/src/encoder.rs +++ b/crates/gamut-png/src/encoder.rs @@ -8,7 +8,9 @@ use gamut_core::{ }; use gamut_deflate::{DeflateEncoder, Level}; -use crate::ancillary::{Ancillary, PhysicalUnit, SrgbIntent, WrittenHeader}; +use crate::ancillary::{ + Ancillary, PaletteOrigin, PhysicalUnit, SrgbIntent, WrittenHeader, WrittenPalette, +}; use crate::backend::{IdatDeflater, IdatInfo, Registry, run_deflaters}; use crate::chunk::{self, SIGNATURE}; use crate::color::ColorType; @@ -345,7 +347,11 @@ impl PngEncoder { WrittenHeader { color: ColorType::Indexed, bit_depth: depth, - plte: Some(&plte), + palette: Some(WrittenPalette { + plte: &plte, + trns, + origin: PaletteOrigin::Caller, + }), }, |out| { chunk::write_chunk(out, *b"PLTE", &plte); @@ -765,7 +771,11 @@ impl PngEncoder { WrittenHeader { color: ColorType::Indexed, bit_depth: depth, - plte: Some(&plte), + palette: Some(WrittenPalette { + plte: &plte, + trns: trns.as_deref(), + origin: PaletteOrigin::Derived, + }), }, |out| { chunk::write_chunk(out, *b"PLTE", &plte); diff --git a/crates/gamut-png/tests/ancillary_colour_type.rs b/crates/gamut-png/tests/ancillary_colour_type.rs index f8e6b24d..a67a3564 100644 --- a/crates/gamut-png/tests/ancillary_colour_type.rs +++ b/crates/gamut-png/tests/ancillary_colour_type.rs @@ -97,6 +97,111 @@ fn keyable_rgba(side: u32) -> Vec { buf } +/// A sprite whose invisible pixels carry noise until cleanup zeroes them to `(0, 0, 0, 0)`, with +/// opaque black among its three visible colours. After cleanup the derived palette holds **two** +/// entries with the triple `[0, 0, 0]` — the transparent one first, by the encoder's +/// transparent-first ordering — so a black background has to choose between them. +fn black_on_transparent_rgba(side: u32) -> Vec { + let mut buf = Vec::with_capacity((side * side * 4) as usize); + for y in 0..side { + for x in 0..side { + let cx = i64::from(x) - i64::from(side) / 2; + let cy = i64::from(y) - i64::from(side) / 2; + if cx * cx + cy * cy >= (i64::from(side) * i64::from(side)) / 9 { + // Invisible noise: an avalanche hash of the position, so that plain RGBA cannot + // compress it and cleanup is what makes the palette reachable. + let h = (x.wrapping_mul(0x9E37_79B9) ^ y.wrapping_mul(0x85EB_CA6B)) + .wrapping_mul(0x27D4_EB2F); + let [a, b, c, _] = h.to_be_bytes(); + buf.extend_from_slice(&[a, b, c, 0]); + } else { + // Visible pixels pick one of three colours pseudo-randomly, so that the palette + // (two bits per pixel) beats plain RGBA (four bytes per pixel) on real bytes + // rather than losing the race to a stripe pattern DEFLATE matches for free. + let h = (x.wrapping_mul(0x1656_67B1) ^ y.wrapping_mul(0xC2B2_AE35)) + .wrapping_mul(0x9E37_79B9); + buf.extend_from_slice(match (h >> 24) % 3 { + 0 => &[0, 0, 0, 255], + 1 => &INK, + _ => &PAPER, + }); + } + } + } + buf +} + +/// The alpha of palette entry `index` — 255 past the end of `tRNS` (§11.3.2.1). +fn palette_alpha(trns: Option<&[u8]>, index: usize) -> u8 { + trns.and_then(|t| t.get(index).copied()).unwrap_or(255) +} + +#[test] +fn an_rgb_background_names_the_opaque_entry_not_the_transparent_twin() { + let src = black_on_transparent_rgba(64); + let png = encode_rgba( + &encoder() + .with_transparent_cleanup(true) + .with_background_rgb(0, 0, 0), + 64, + &src, + ); + + assert_eq!( + written_colour_type(&png), + COLOR_PALETTE, + "precondition: the palette won" + ); + let plte = read_chunk(&png, b"PLTE").expect("an indexed file carries PLTE"); + let trns = read_chunk(&png, b"tRNS"); + let blacks: Vec = plte + .as_chunks::<3>() + .0 + .iter() + .enumerate() + .filter(|(_, entry)| **entry == [0, 0, 0]) + .map(|(i, _)| i) + .collect(); + let transparent = blacks + .iter() + .copied() + .find(|&i| palette_alpha(trns.as_deref(), i) == 0) + .expect("precondition: cleanup left a transparent black entry"); + let opaque = blacks + .iter() + .copied() + .find(|&i| palette_alpha(trns.as_deref(), i) == 255) + .expect("precondition: the visible black is an opaque entry"); + assert!( + transparent < opaque, + "precondition: the transparent twin comes first, so a first-match search would pick it" + ); + assert_eq!( + read_chunk(&png, b"bKGD"), + Some(vec![opaque as u8]), + "the background is a colour a viewer sees: the opaque entry, not its transparent twin" + ); +} + +#[test] +fn a_palette_index_background_is_dropped_under_an_encoder_derived_palette() { + // The palette wins here, but it is the encoder's palette, in the encoder's order: the + // caller's index names an entry in a palette the caller never saw. + let src = two_colour_rgba(64); + let png = encode_rgba(&encoder().with_background_index(1), 64, &src); + + assert_eq!( + written_colour_type(&png), + COLOR_PALETTE, + "precondition: the palette won" + ); + assert_eq!( + read_chunk(&png, b"bKGD"), + None, + "an index into a palette the caller did not supply refers to nothing" + ); +} + #[test] fn a_palette_index_background_is_dropped_when_the_unreduced_stream_wins() { // 64 colours at 32x32: the palette's flat PLTE+tRNS bytes are not amortised, so the unreduced From e1e39b3196d57b92dddde11cdbbcb57285317603 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Sun, 6 Sep 2026 00:42:20 -0400 Subject: [PATCH 53/54] test(png): count the tally's lookup probes instead of trusting its shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The structural test pinned the index's content — one entry per type, each at its stats position — which a `record` that scans `stats` linearly and also maintains the index satisfies unchanged, so it did not falsify the quadratic walk the index replaced. `ChunkTally` now carries a `#[cfg(test)]` probe counter that `record` increments once per entry examined (one for a hash lookup; a linear scan would have to account one per entry compared), and a new inline test asserts N chunks cost exactly N probes both with every type distinct and with a single type — the O(N) claim by count rather than by clock. Two comments corrected: the structural test's doc no longer claims a linear scan would fail it, and the at-scale public test no longer says the fixture "would not complete under the defect" — it took about 17 s; the probe count, not that test's duration, separates the two. --- crates/gamut-png/src/deconstruct.rs | 61 ++++++++++++++++++++++++---- crates/gamut-png/tests/accounting.rs | 7 ++-- 2 files changed, 58 insertions(+), 10 deletions(-) diff --git a/crates/gamut-png/src/deconstruct.rs b/crates/gamut-png/src/deconstruct.rs index 1ab9ca4c..5ad14540 100644 --- a/crates/gamut-png/src/deconstruct.rs +++ b/crates/gamut-png/src/deconstruct.rs @@ -423,6 +423,10 @@ struct ChunkTally { stats: Vec, /// Type → its index in `stats`. Dropped at the end of the walk; never surfaced. index: HashMap<[u8; 4], usize>, + /// Lookup work done so far, in entries examined — the probe that makes this type's + /// complexity assertable by count rather than by clock. See [`record`](Self::record). + #[cfg(test)] + probes: usize, } impl ChunkTally { @@ -431,11 +435,22 @@ impl ChunkTally { Self { stats: Vec::new(), index: HashMap::new(), + #[cfg(test)] + probes: 0, } } /// Adds one chunk of `chunk_type` carrying `payload_len` payload bytes. + /// + /// The lookup accounts one probe per entry it examines: a hash lookup examines one, so a + /// file of N chunks costs N probes whatever its number of distinct types. Any replacement + /// lookup strategy must account its work here the same way — a linear scan, one per entry + /// compared — which is what lets the inline test bound the walk at O(N) instead of timing it. fn record(&mut self, chunk_type: [u8; 4], payload_len: usize) { + #[cfg(test)] + { + self.probes += 1; + } match self.index.get(&chunk_type) { Some(&at) => { self.stats[at].count += 1; @@ -998,13 +1013,12 @@ mod tests { ); } - /// The tally answers "have I seen this type?" from its index, never by scanning `stats` — - /// which is what makes a file of N distinct chunk types cost O(N) rather than O(N²). That is - /// a structural claim, so it is asserted structurally: after any sequence of records, the - /// index holds exactly one entry per distinct type, and each maps to the position in `stats` - /// whose entry carries that type. A `record` that failed to index a new type, or indexed it at - /// the wrong position, would fall back to nothing at all — the lookup below has no linear - /// scan to fall back to — and this is where that shows. Timing belongs to `benches/`. + /// The index is what `record` answers "have I seen this type?" from, so it has to be + /// complete and right: after any sequence of records it holds exactly one entry per distinct + /// type, each mapping to the position in `stats` whose entry carries that type, and the + /// counts show both arms of `record` ran. This pins the index's *content*; it does not by + /// itself rule out a `record` that scans `stats` and also maintains the index — the probe + /// count in `the_tally_probes_once_per_chunk_whatever_the_number_of_distinct_types` does. #[test] fn the_tally_index_names_every_recorded_type_at_its_position() { let mut tally = ChunkTally::new(); @@ -1036,6 +1050,39 @@ mod tests { } } + /// The complexity claim itself, by count rather than by clock: N chunks cost N lookup + /// probes however many distinct types they use. A linear scan over `stats` — the defect the + /// index replaced, quadratic in the number of distinct types — accounts one probe per entry + /// compared and lands near N²/2 here; the hash lookup accounts exactly one per record. Two + /// files of the same chunk count, one with every type distinct and one with a single type, + /// must cost the same. Wall-clock timing of the same claim belongs to `benches/`. + #[test] + fn the_tally_probes_once_per_chunk_whatever_the_number_of_distinct_types() { + const CHUNKS: usize = 2048; + let mut distinct = ChunkTally::new(); + for i in 0..CHUNKS as u32 { + distinct.record(i.to_be_bytes(), 0); + } + let mut repeated = ChunkTally::new(); + for _ in 0..CHUNKS { + repeated.record(*b"crUD", 0); + } + assert_eq!( + distinct.stats.len(), + CHUNKS, + "precondition: every type distinct" + ); + assert_eq!(repeated.stats.len(), 1, "precondition: one type throughout"); + assert_eq!( + distinct.probes, CHUNKS, + "one probe per record with every type distinct" + ); + assert_eq!( + repeated.probes, CHUNKS, + "and the same with one type: the count is O(N)" + ); + } + #[test] fn the_inflation_ratio_is_inclusive_and_saturates() { // A stream may inflate to exactly sixty-four times its length and not one byte more. diff --git a/crates/gamut-png/tests/accounting.rs b/crates/gamut-png/tests/accounting.rs index 3601e37d..b072c70c 100644 --- a/crates/gamut-png/tests/accounting.rs +++ b/crates/gamut-png/tests/accounting.rs @@ -191,12 +191,13 @@ fn synthetic_type(i: usize) -> [u8; 4] { /// Accumulating the per-type totals with a linear scan made this quadratic in the file length /// (measured: 4.8 MB → 40.9 s), reachable from `gamut inspect` on an untrusted file. /// -/// The complexity claim itself is pinned structurally, inside the crate, where the tally's index -/// is visible (`deconstruct::tests::the_tally_index_names_every_recorded_type_at_its_position`): +/// The complexity claim itself is pinned inside the crate, where the tally's lookup work can be +/// counted (`deconstruct::tests::the_tally_probes_once_per_chunk_whatever_the_number_of_distinct_types`): /// a wall-clock ratio between two runs in the blocking gate is flaky under `llvm-cov` and parallel /// test binaries, and timing belongs to `benches/`. What this test adds from the public side is /// the *content* at scale — 262 144 distinct types against the same bytes with one type — which -/// is what the index exists to produce, and a fixture that would not complete under the defect. +/// is what the index exists to produce. Under the defect this fixture took about 17 s (it did +/// complete); it is the probe count, not this test's duration, that tells the two apart. #[test] fn every_distinct_chunk_type_gets_its_own_tally_entry_at_scale() { /// Empty chunks between IHDR and IEND: 12 bytes each, so ~3.1 MB per half. From fac39dc2820a93831d717cfbc15ffdb517ee88b6 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Sun, 6 Sep 2026 00:43:27 -0400 Subject: [PATCH 54/54] docs(png): qualify the bKGD/sBIT contract by axis and state what a bomb still costs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `with_significant_bits`, `with_background_gray/rgb/index`: say that the chunk is emitted for the colour type actually written, converted where lossless and omitted without error where the written header cannot carry it, and that an index survives only against the caller's own palette. - `ancillary` module doc and `STATUS.md`: the "converted or omitted" contract holds across colour types; on the depth axis a `bKGD` sample is range-checked but not rescaled with a 16→8 demotion or sub-byte packing, which is #501. - `INFLATION_RATIO`: the worst case a few-kilobyte file can still cost, numerically — the decoder's own default exposure of 64 MiB plus one byte per scanline (64 MiB + 4 KiB for 4096×4096 RGBA8, 128 MiB for a one-pixel- wide column) — so the ratio's job is stated as stopping a raised budget, not shrinking the default one. --- crates/gamut-png/STATUS.md | 11 ++++++++--- crates/gamut-png/src/ancillary.rs | 12 +++++++++--- crates/gamut-png/src/deconstruct.rs | 8 ++++++++ crates/gamut-png/src/encoder.rs | 28 ++++++++++++++++++++++++++++ 4 files changed, 53 insertions(+), 6 deletions(-) diff --git a/crates/gamut-png/STATUS.md b/crates/gamut-png/STATUS.md index 80b0af94..8e72d11e 100644 --- a/crates/gamut-png/STATUS.md +++ b/crates/gamut-png/STATUS.md @@ -180,9 +180,13 @@ cost model; a model good enough to skip the losing candidate is [#480]'s remaind **Chunks that follow the race.** `bKGD` and `sBIT` have a payload whose shape is the colour type, and the race decides the colour type after they were set. Both are resolved against the header actually written — RGBA `sBIT` loses its alpha entry under RGB or a palette, an RGB or grey background under a -palette becomes the index of its entry, a grey RGB triple collapses to one grey sample — and omitted -where no lossless conversion exists, since a payload shaped for the wrong colour type is a chunk -libpng rejects and drops. +palette becomes the index of its entry (an opaque entry where a transparent twin exists), a grey RGB +triple collapses to one grey sample — and omitted, without error, where no lossless conversion +exists, since a payload shaped for the wrong colour type is a chunk libpng rejects and drops. A +caller's palette *index* survives only on the `encode_indexed8` path, whose palette is the caller's; +under an encoder-derived palette it names nothing and is omitted. This holds across colour +**types**; on the depth axis a `bKGD` sample is range-checked but not rescaled with a 16→8 demotion +or a sub-byte packing — that is [#501]. [#437]: https://github.com/visualcommons/gamut/issues/437 [#478]: https://github.com/visualcommons/gamut/issues/478 @@ -192,3 +196,4 @@ libpng rejects and drops. [#482]: https://github.com/visualcommons/gamut/issues/482 [#483]: https://github.com/visualcommons/gamut/issues/483 [#484]: https://github.com/visualcommons/gamut/issues/484 +[#501]: https://github.com/visualcommons/gamut/issues/501 diff --git a/crates/gamut-png/src/ancillary.rs b/crates/gamut-png/src/ancillary.rs index 19547b45..ef861412 100644 --- a/crates/gamut-png/src/ancillary.rs +++ b/crates/gamut-png/src/ancillary.rs @@ -8,9 +8,15 @@ //! palette, a greyscale or a colour-keyed truecolour image in place of the input's layout, and the //! palette and colour-key candidates are *raced* against the unreduced encoding on compressed //! size, so which one lands is not knowable when the chunk is set. Both are therefore emitted for -//! the header actually written — converted where a lossless conversion exists, omitted otherwise -//! ([`bkgd_for`], [`sbit_for`]) — rather than verbatim, because a payload shaped for the wrong -//! colour type is a chunk a reader rejects and drops. +//! the header actually written — converted across colour types where a lossless conversion +//! exists, omitted otherwise ([`bkgd_for`], [`sbit_for`]) — rather than verbatim, because a +//! payload shaped for the wrong colour type is a chunk a reader rejects and drops. +//! +//! That contract holds across colour **types**. On the depth axis it is weaker: a `bKGD` sample is +//! checked against the written depth and omitted when out of range, but it is not *rescaled* when +//! auto-reduce demoted the samples (16→8 by `v / 257`, sub-byte grey by the depth's scale), so a +//! sample inside the written range keeps its input-depth value. That is issue #501, not this +//! module's claim. use gamut_deflate::{DeflateEncoder, Level}; diff --git a/crates/gamut-png/src/deconstruct.rs b/crates/gamut-png/src/deconstruct.rs index 5ad14540..3ad0bb7e 100644 --- a/crates/gamut-png/src/deconstruct.rs +++ b/crates/gamut-png/src/deconstruct.rs @@ -763,6 +763,14 @@ fn fits_decode_budget(header: &ihdr::Ihdr, max_image_bytes: usize) -> bool { /// bomb — and above [`DEFAULT_MAX_IMAGE_BYTES`] the walk stops assuming the former. A flat 16k×16k /// image is the one real file this declines, and it is declined as the reader's budget /// ([`SkippedFilterScan::OverBudget`]), not as damage. +/// +/// What a small hostile file can still cost, numerically: inside the default budget the ratio +/// does not apply, so a few-kilobyte stream declaring an image that just fits 64 MiB is inflated +/// to that image's filtered length — 64 MiB plus one byte per scanline, 64 MiB + 4 KiB for +/// 4096×4096 RGBA8 and up to 128 MiB for a degenerate one-pixel-wide greyscale column. That is +/// exactly the decoder's own default exposure to the same header (`PngDecoder` allocates it), +/// so the walk is never a cheaper bomb target than a decode; the ratio only stops a *raised* +/// image budget from becoming one. const INFLATION_RATIO: usize = 64; /// Whether a stream of `idat_len` compressed bytes may be inflated to `filtered_len`: it must diff --git a/crates/gamut-png/src/encoder.rs b/crates/gamut-png/src/encoder.rs index 1e7477e5..499bf061 100644 --- a/crates/gamut-png/src/encoder.rs +++ b/crates/gamut-png/src/encoder.rs @@ -207,6 +207,13 @@ impl PngEncoder { /// Records the number of significant bits per channel (sBIT chunk). The length must match the /// colour type (1 for grey, 2 for grey+alpha, 3 for RGB/indexed, 4 for RGBA). + /// + /// Emitted for the colour type actually **written**, which under + /// [`with_auto_reduce`](Self::with_auto_reduce) may differ from the input's: the entries are + /// converted where that is lossless (an alpha entry dropped with its channel, RGB collapsed + /// to grey where the three agree) and the chunk is **omitted, without error,** where the + /// written colour type or depth cannot carry them — a reduction is never refused to keep a + /// metadata chunk. See `STATUS.md`, "Chunks that follow the race". #[must_use] pub fn with_significant_bits(mut self, bits: &[u8]) -> Self { self.ancillary.sbit = Some(bits.to_vec()); @@ -214,6 +221,12 @@ impl PngEncoder { } /// Records a greyscale background colour (bKGD chunk) for greyscale images. + /// + /// Emitted for the colour type actually **written**, which under + /// [`with_auto_reduce`](Self::with_auto_reduce) may differ from the input's: converted where + /// that is lossless (to an RGB triple, or to the palette entry holding the grey) and + /// **omitted, without error,** where the written colour type or depth cannot carry it. See + /// `STATUS.md`, "Chunks that follow the race". #[must_use] pub fn with_background_gray(mut self, gray: u16) -> Self { self.ancillary.bkgd = Some(gray.to_be_bytes().to_vec()); @@ -221,6 +234,13 @@ impl PngEncoder { } /// Records an RGB background colour (bKGD chunk) for truecolour images. + /// + /// Emitted for the colour type actually **written**, which under + /// [`with_auto_reduce`](Self::with_auto_reduce) may differ from the input's: converted where + /// that is lossless (to one grey sample where the channels agree, or to the palette entry + /// holding the colour — an opaque one where a transparent twin exists) and **omitted, without + /// error,** where the written colour type or depth cannot carry it. See `STATUS.md`, "Chunks + /// that follow the race". #[must_use] pub fn with_background_rgb(mut self, red: u16, green: u16, blue: u16) -> Self { let mut data = Vec::with_capacity(6); @@ -232,6 +252,14 @@ impl PngEncoder { } /// Records a palette-index background colour (bKGD chunk) for indexed images. + /// + /// The index names an entry of the palette **you** supply to + /// [`encode_indexed8`](Self::encode_indexed8), and is emitted only there (and only in range). + /// Under [`with_auto_reduce`](Self::with_auto_reduce) the palette, if one is written, is the + /// encoder's own, in an order this index never referred to, so the chunk is **omitted, + /// without error** — set the background as a colour ([`with_background_rgb`](Self::with_background_rgb)) + /// to have it resolved against whatever is written. See `STATUS.md`, "Chunks that follow the + /// race". #[must_use] pub fn with_background_index(mut self, index: u8) -> Self { self.ancillary.bkgd = Some(vec![index]);