From fc9e5898411a2e2e8296de73fceeca556d0acc15 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 06:50:27 -0400 Subject: [PATCH 01/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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]); From 21602edd3c84b2aea5e369e4dbd80b8239f21d62 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Sun, 6 Sep 2026 04:17:57 -0400 Subject: [PATCH 55/94] feat(png): carry the C2PA manifest store in the caBX chunk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read and write the C2PA manifest store (C2PA 2.4 §A.3.2) as one more raw, MetadataBlock-ready ancillary payload, the way eXIf/iCCP/XMP already travel. Decode: `DecodedPng::c2pa` / `PngMetadata::c2pa` carry the first `caBX` verbatim and uncompressed; a later `caBX` is counted in `c2pa_duplicates` (saturating), never concatenated, since PNG has no multi-chunk store. The store is charged to the cumulative `with_max_metadata_bytes` budget like every other attacker-sized payload — skipped past the remainder, not an error. Encode: `with_c2pa(store)` embeds a caller-computed store and `with_c2pa_reserved(len)` writes `len` zero bytes in its place, as the last chunk before the first IDAT (after PLTE/tRNS and every other ancillary chunk) so a reservation is filled by a second equal-length encode that changes no byte outside the chunk. `encode_with_report` and `PngReport::c2pa` name the chunk's whole span — length, type, payload and CRC — as `C2paSpan`, the `c2pa.hash.data` exclusion §18.5.4 asks for. `EncodeImage` is untouched. The chunk type is spelled once, `chunk::CABX`, with its property bits asserted per PNG §5.4 Table 6: ancillary and private set, reserved clear, and unsafe-to-copy *clear* on the fourth byte — the polarity the issue's prose had backwards. That bit is the container's own enforcement of the facade's no-copy-forward law (`C2paPolicy`). libpng carries `caBX` as an unknown chunk, which is the framing proof: for the same payload it frames the same length/type/CRC bytes as gamut, decodes gamut's file pixel-exact with the chunk in place, and gamut reads the store back from libpng's file. The behavioural oracle (c2pa-rs) is #447. Refs #440 --- crates/gamut-png/README.md | 17 +- crates/gamut-png/STATUS.md | 52 ++++ crates/gamut-png/src/ancillary.rs | 53 +++- crates/gamut-png/src/chunk.rs | 119 ++++++++ crates/gamut-png/src/decoded.rs | 116 +++++++- crates/gamut-png/src/decoder.rs | 23 +- crates/gamut-png/src/deconstruct.rs | 32 +- crates/gamut-png/src/encoder.rs | 92 +++++- crates/gamut-png/src/lib.rs | 21 +- crates/gamut-png/tests/c2pa.rs | 445 ++++++++++++++++++++++++++++ crates/gamut-png/tests/metadata.rs | 6 + 11 files changed, 943 insertions(+), 33 deletions(-) create mode 100644 crates/gamut-png/tests/c2pa.rs diff --git a/crates/gamut-png/README.md b/crates/gamut-png/README.md index ab58f048..22c6bb2b 100644 --- a/crates/gamut-png/README.md +++ b/crates/gamut-png/README.md @@ -14,10 +14,16 @@ Graphics, W3C 3rd edition) images: concern at higher levels. - **Spec-compliant decoding** (issue #249). Every colour type and bit depth, Adam7 interlacing, all five filters, and ancillary metadata surfaced as raw payloads (eXIf, inflated iCCP, XMP, - tEXt/zTXt/iTXt) ready for `gamut_metadata::MetadataBlock`, plus parsed gAMA/cHRM/sRGB/cICP - values. Hostile input is bounded: configurable dimension caps and byte budgets guard every - allocation, and zlib bombs (IDAT or metadata) fail cleanly. Inflation uses `miniz_oxide`, the - workspace's blessed decode-side inflate. + tEXt/zTXt/iTXt, and the C2PA manifest store in `caBX`) ready for + `gamut_metadata::MetadataBlock`, plus parsed gAMA/cHRM/sRGB/cICP values. Hostile input is + bounded: configurable dimension caps and byte budgets guard every allocation, and zlib bombs + (IDAT or metadata) fail cleanly. Inflation uses `miniz_oxide`, the workspace's blessed + decode-side inflate. +- **C2PA carriage** (issue #440). The manifest store is located, bounded, carried and reserved — + never parsed or judged. `with_c2pa` / `with_c2pa_reserved` put it as the last chunk before + `IDAT`, and `encode_with_report` / `PngReport::c2pa` name the chunk's whole span (length, type, + payload, CRC) for the `c2pa.hash.data` exclusion, so a reservation is filled by a second encode + that changes no byte outside it. Validation is `c2pa-rs`'s. - **Memory-safe.** 100% safe Rust (`#![deny(unsafe_code)]`). ## Usage @@ -50,7 +56,8 @@ Built incrementally; each phase is conformance-checked against libpng (see [STAT Encoder scope: all five colour types, bit depths 1/2/4/8/16, palette, the five scanline filters, lossless reductions over every input layout (palette, grey, alpha drop, sub-byte grey packing, 16→8 demotion), the standard colour/text ancillary chunks, and embedded metadata -(eXIf/iCCP/iTXt). Decoder scope: everything above plus Adam7 **decoding** and decode limits. +(eXIf/iCCP/iTXt, and the C2PA manifest store with a reserve-then-fill slot). Decoder scope: +everything above plus Adam7 **decoding** and decode limits. Out of scope: Adam7 *encoding* and animation (APNG decodes as its default image). ## Validation diff --git a/crates/gamut-png/STATUS.md b/crates/gamut-png/STATUS.md index 8e72d11e..b3ec162e 100644 --- a/crates/gamut-png/STATUS.md +++ b/crates/gamut-png/STATUS.md @@ -39,6 +39,7 @@ opts into narrowing. That is distinct from the encoder's *lossless* auto-reduce | 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 | +| C1 | C2PA 2.4 §A.3.2, §18.5.4 | **C2PA carriage** (#440): the `caBX` manifest store — raw decode surface (`c2pa`; first wins, duplicates counted, under the metadata budget); `with_c2pa` / `with_c2pa_reserved` as the last chunk before `IDAT`; the whole-chunk exclusion span from `encode_with_report` and `PngReport::c2pa` (see [C2PA](#c2pa-manifest-store-issue-440)) | ✅ done | ## Decoder phases (issue #249) @@ -52,6 +53,57 @@ opts into narrowing. That is distinct from the encoder's *lossless* auto-reduce | 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 | +## C2PA manifest store (issue #440) + +Part of epic #239. gamut **locates, bounds, carries and reserves** the C2PA manifest store; it +never parses or judges it. The store is opaque bytes plus byte ranges here, and validation is +`c2pa-rs`'s (`references/c2pa/README.md` draws the boundary). + +**Carriage.** The store is the data of a `caBX` chunk, uncompressed (C2PA 2.4 §A.3.2). The chunk +type is spelled once, in `chunk::CABX`, and its *property bits* are asserted rather than only its +letters: ancillary and private (bit 5 set on bytes 0 and 1), reserved bit clear, and — the point — +**unsafe to copy** (bit 5 *clear* on byte 3; PNG §5.4 Table 6 gives that polarity, and the issue's +prose had it backwards). A PNG editor that rewrites the image must drop an unrecognised +unsafe-to-copy chunk, which is the container enforcing the same no-copy-forward law +`gamut-metadata`'s `C2paPolicy` states for the facade: a store is bound to the bytes it was signed +over, so one copied forward into a rewritten file is invalid by construction. + +**Decode.** `DecodedPng::c2pa` / `PngMetadata::c2pa` carry the first `caBX` verbatim, ready for +`MetadataBlock::C2pa`. Exactly one store per file: a later `caBX` is counted in `c2pa_duplicates` +(saturating at 255), never concatenated — PNG has no multi-chunk store, unlike JPEG's APP11 run. +The store is attacker-sized like every ancillary payload, so its bytes are charged to the one +cumulative `with_max_metadata_bytes` budget; a store past the remainder is skipped, not an error, +and — skipped — is still the file's first store, so a smaller one after it is a duplicate rather +than a substitute. A `caBX` whose CRC does not match is skipped (§13.1) on both the decode and the +byte-accounting side, so the two agree on which chunk is the store. + +**Encode.** `with_c2pa(store)` embeds a store computed for this file; `with_c2pa_reserved(len)` +writes `len` zero bytes in its place. Either is emitted as the **last** chunk before the first +`IDAT` — after `PLTE`/`tRNS` and every other ancillary chunk — so the chunk's offset depends only +on what precedes it and every later byte is `IDAT`/`IEND`. §A.3.2 asks only that it precede +`IDAT`; last-before-`IDAT` is what makes the reserve-then-fill flow a no-move: encode with the +reservation, hash with the chunk's span excluded, then encode again with the finished store of the +same length — the output is byte-reproducible, so the second file differs from the first only in +the payload and the chunk CRC. `tests/c2pa.rs` pins that as an exact-byte diff. + +**Exclusion span.** `encode_with_report` (for the file just written) and `PngReport::c2pa` (for +any file) name the chunk's **whole** span — length, type, payload and CRC — as `C2paSpan`, with +the payload bracketed inside it. §18.5.4 says the length and type go inside the exclusion; the CRC +must too, since it changes with the payload, and a `c2pa.hash.data` computed over any of them +breaks on the store's first write. The span is derived from the same chunk walk the byte +accounting uses, so it is always one of the report's claimed segments. + +**Oracle.** libpng has no C2PA support and carries `caBX` as an unknown chunk — which is exactly +the proof needed for framing: for the same payload it must produce the same length, type and CRC +bytes as gamut, it must decode gamut's file pixel-exact with the chunk in place, and gamut must read +the store from a libpng-written file. The behavioural oracle (`c2pa-rs`, against which a store's +hash assertion can be checked over the excluded span) is issue #447. + +**Not done, by design.** No in-place fill helper: a reservation is filled by a second encode, which +costs a second encode. No JUMBF parsing, not even of the outer box length. `gamut convert` does not +carry a store across a re-encode (that is the facade's `C2paPolicy` law, and the CLI's own path is +#448/#483). + ## Efficiency (issue #224) Correctness was settled long before efficiency was measured. This section is the measured state: diff --git a/crates/gamut-png/src/ancillary.rs b/crates/gamut-png/src/ancillary.rs index ef861412..7c697e8e 100644 --- a/crates/gamut-png/src/ancillary.rs +++ b/crates/gamut-png/src/ancillary.rs @@ -3,6 +3,12 @@ //! 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`. //! +//! One chunk here is not PNG's own: the C2PA manifest store, `caBX` (C2PA 2.4 §A.3.2). It is +//! emitted **last** of everything before `IDAT`, so that its offset depends only on the chunks +//! that precede it and every byte after it is `IDAT` or `IEND` — which is what lets a reserved +//! store be filled in place by a second encode of equal length without moving a byte outside +//! the chunk. §A.3.2 asks only that it precede `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 @@ -115,6 +121,9 @@ pub(crate) struct Ancillary { pub iccp: Option<(String, Vec)>, /// eXIf: raw EXIF/TIFF bytes (the chunk payload starts with the TIFF byte-order marker). pub exif: Option>, + /// caBX: the C2PA manifest store, raw and uncompressed (C2PA 2.4 §A.3.2) — or a run of zero + /// bytes reserving its place. Emitted last, immediately before the first `IDAT`. + pub c2pa: Option>, /// tEXt / zTXt / iTXt entries, emitted in insertion order. texts: Vec, } @@ -189,7 +198,8 @@ impl Ancillary { } } - /// Emits the remaining ancillary chunks that precede `IDAT` (after any `PLTE`/`tRNS`). + /// Emits the remaining ancillary chunks that precede `IDAT` (after any `PLTE`/`tRNS`), the + /// C2PA manifest store last of all so that it is the chunk immediately before `IDAT`. /// `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( @@ -221,6 +231,11 @@ impl Ancillary { for entry in &self.texts { write_text(out, entry, effort); } + // Last, so nothing whose size could shift the store follows it: a reservation filled by + // a second encode of equal length keeps every offset outside this chunk. + if let Some(store) = &self.c2pa { + chunk::write_chunk(out, chunk::CABX, store); + } } } @@ -517,6 +532,42 @@ mod tests { assert_eq!(find_chunk(&out, b"tEXt").unwrap(), b"Title\0hi".to_vec()); } + /// The manifest store is the last chunk the pre-IDAT pass writes, after every text entry + /// added before or after it was set, and it is written raw: no keyword, no compression byte. + #[test] + fn the_c2pa_store_is_written_raw_and_last_before_idat() { + let mut a = Ancillary::default(); + a.add_text_latin1("Before", "set first"); + a.c2pa = Some(b"\0\0\0\x1fjumb".to_vec()); + a.add_text_compressed("After", "set later"); + a.set_time(2026, 9, 6, 0, 0, 0); + let mut out = vec![0u8; 8]; + a.write_post_plte(&mut out, DeflateEncoder::DEFAULT_EFFORT, RGB8); + + let mut types = Vec::new(); + let mut i = 8; + while i + 12 <= out.len() { + let len = u32::from_be_bytes([out[i], out[i + 1], out[i + 2], out[i + 3]]) as usize; + types.push(out[i + 4..i + 8].to_vec()); + i += 12 + len; + } + assert_eq!(types.last().map(Vec::as_slice), Some(&b"caBX"[..])); + assert_eq!( + types.iter().filter(|t| t.as_slice() == b"caBX").count(), + 1, + "exactly one store" + ); + assert_eq!( + find_chunk(&out, b"caBX"), + Some(b"\0\0\0\x1fjumb".to_vec()), + "the payload is the store verbatim" + ); + // Unset, no chunk at all. + let mut none = vec![0u8; 8]; + Ancillary::default().write_post_plte(&mut none, DeflateEncoder::DEFAULT_EFFORT, RGB8); + assert_eq!(find_chunk(&none, b"caBX"), None); + } + #[test] fn iccp_and_exif_framing() { let a = Ancillary { diff --git a/crates/gamut-png/src/chunk.rs b/crates/gamut-png/src/chunk.rs index 67e9cbb5..4ef6e715 100644 --- a/crates/gamut-png/src/chunk.rs +++ b/crates/gamut-png/src/chunk.rs @@ -13,6 +13,17 @@ use crate::crc32::Crc32; /// The 8-byte PNG file signature (`\x89PNG\r\n\x1a\n`). pub(crate) const SIGNATURE: [u8; 8] = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; +/// The C2PA manifest-store chunk type (C2PA 2.4 §A.3.2), spelled for its property bits (PNG +/// §5.4, Table 6): `c` ancillary, `a` private, `B` reserved bit clear, `X` **unsafe to copy**. +/// +/// The last bit is the point. A PNG editor that rewrites the image must drop an unrecognised +/// unsafe-to-copy chunk (§14.2), and a C2PA manifest store is bound to the exact bytes it was +/// signed over (§18.5), so a store copied forward into a rewritten file is invalid by +/// construction. That is the same no-copy-forward law `gamut_metadata::C2paPolicy` states for +/// the facade, here enforced by the container's own naming convention — which is why the type is +/// spelled in exactly one place and its *bits* are asserted, not only its letters. +pub(crate) const CABX: [u8; 4] = *b"caBX"; + /// Appends a complete chunk (`length`, `type`, `data`, `CRC`) to `out`. pub(crate) fn write_chunk(out: &mut Vec, chunk_type: [u8; 4], data: &[u8]) { out.extend_from_slice(&(data.len() as u32).to_be_bytes()); @@ -45,6 +56,52 @@ impl RawChunk<'_> { } } +/// Where a C2PA manifest store sits in a PNG: the `caBX` chunk's whole span and, inside it, the +/// store's own bytes. Reported by +/// [`PngEncoder::encode_with_report`](crate::PngEncoder::encode_with_report) for a file just +/// written and by [`PngReport::c2pa`](crate::PngReport::c2pa) for any file. +/// +/// Non-exhaustive: a later revision may name a further range without a breaking change. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub struct C2paSpan { + /// The whole chunk — length, type, payload **and CRC**, `12 + payload` bytes. The range a + /// `c2pa.hash.data` exclusion must cover (C2PA 2.4 §18.5.4): the store's bytes change when it + /// is written, the length field when it is resized, and the CRC with either, so a hash that + /// keeps any of them breaks on the store's first update. + pub chunk: Range, + /// The store's bytes alone — the chunk's payload, `chunk.start + 8 .. chunk.end - 4`. What a + /// signer overwrites when it fills a reservation. + pub payload: Range, +} + +impl C2paSpan { + /// The span of a `caBX` chunk occupying `chunk` (framing included), single-sourcing the + /// framing arithmetic for both reports. + pub(crate) fn of(chunk: Range) -> Self { + Self { + payload: chunk.start + 8..chunk.end - 4, + chunk, + } + } +} + +/// Locates the manifest store in a PNG: the first CRC-valid `caBX` chunk, or `None`. +/// +/// The first CRC-valid one, because that is the chunk the decoder surfaces as its `c2pa` payload +/// (§13.1 skips a CRC mismatch), so the span a caller excludes from a hash is the store it reads. +/// Stops at end of input or at the first chunk that does not frame; a stream that is not a PNG +/// has no store. +pub(crate) fn find_c2pa(png: &[u8]) -> Option { + let mut reader = ChunkReader::new(png).ok()?; + while let Ok(Some(chunk)) = reader.next_chunk() { + if chunk.chunk_type == CABX && chunk.crc_ok { + return Some(C2paSpan::of(chunk.range)); + } + } + None +} + /// Iterates the chunks of a PNG stream after validating the signature (§5.2). pub(crate) struct ChunkReader<'a> { rest: &'a [u8], @@ -204,6 +261,68 @@ mod tests { assert!(chunk.is_ancillary()); } + /// The property bits of `caBX` (PNG §5.4, Table 6), asserted on the constant rather than on + /// its letters: bit 5 of each byte is the property, and a typo that flips one — `cABX`, a + /// public chunk; `caBx`, one an editor may copy forward — still reads as a plausible name. + /// C2PA §A.3.2 requires ancillary, private and not safe to copy; PNG §5.4 requires the + /// reserved bit clear. Note the polarity of the fourth byte: **clear** (uppercase) is unsafe + /// to copy. + #[test] + fn cabx_property_bits_are_ancillary_private_reserved_clear_and_unsafe_to_copy() { + const PROPERTY: u8 = 0x20; + assert_ne!(CABX[0] & PROPERTY, 0, "byte 0: ancillary (lowercase)"); + assert_ne!(CABX[1] & PROPERTY, 0, "byte 1: private (lowercase)"); + assert_eq!( + CABX[2] & PROPERTY, + 0, + "byte 2: reserved bit clear (uppercase)" + ); + assert_eq!(CABX[3] & PROPERTY, 0, "byte 3: unsafe to copy (uppercase)"); + assert_eq!(CABX, [0x63, 0x61, 0x42, 0x58]); + } + + /// The span arithmetic at known offsets: a chunk at `33..52` (a 7-byte payload after the + /// signature and IHDR) has its payload at `41..48`. Every byte of the framing is accounted + /// — 4 length, 4 type ahead of the payload, 4 CRC behind it. + #[test] + fn a_c2pa_span_names_the_whole_chunk_and_the_payload_inside_it() { + let mut png = SIGNATURE.to_vec(); + write_chunk(&mut png, *b"IHDR", &[0; 13]); + write_chunk(&mut png, CABX, b"jumbf!!"); + write_chunk(&mut png, *b"IEND", &[]); + let span = find_c2pa(&png).expect("a caBX chunk"); + assert_eq!(span.chunk, 33..52); + assert_eq!(span.payload, 41..48); + assert_eq!(&png[span.payload.clone()], b"jumbf!!"); + assert_eq!(&png[span.chunk.start + 4..span.chunk.start + 8], b"caBX"); + // Nothing but the chunk: the span ends exactly where IEND's length field begins. + assert_eq!(&png[span.chunk.end + 4..span.chunk.end + 8], b"IEND"); + } + + /// The store the span names is the one the decoder reads: a `caBX` whose CRC does not match + /// is skipped on decode (§13.1), so it is skipped here too, and the CRC-valid one after it + /// is the store. A stream with no `caBX`, or no signature, has none. + #[test] + fn find_c2pa_skips_a_crc_mismatch_and_names_the_first_valid_store() { + let mut png = SIGNATURE.to_vec(); + write_chunk(&mut png, *b"IHDR", &[0; 13]); + write_chunk(&mut png, CABX, b"corrupt"); + let last = png.len() - 1; + png[last] ^= 0xFF; // the first store's CRC no longer matches + let valid_start = png.len(); + write_chunk(&mut png, CABX, b"valid"); + write_chunk(&mut png, *b"IEND", &[]); + let span = find_c2pa(&png).expect("the CRC-valid caBX"); + assert_eq!(span.chunk, valid_start..valid_start + 12 + 5); + assert_eq!(&png[span.payload], b"valid"); + + let mut none = SIGNATURE.to_vec(); + write_chunk(&mut none, *b"IHDR", &[0; 13]); + write_chunk(&mut none, *b"IEND", &[]); + assert_eq!(find_c2pa(&none), None); + assert_eq!(find_c2pa(b"not a png"), None); + } + #[test] fn reader_rejects_oversized_length() { let mut png = SIGNATURE.to_vec(); diff --git a/crates/gamut-png/src/decoded.rs b/crates/gamut-png/src/decoded.rs index 903d7c55..32114a7e 100644 --- a/crates/gamut-png/src/decoded.rs +++ b/crates/gamut-png/src/decoded.rs @@ -2,19 +2,22 @@ //! §11.3) — the read-side twin of the `ancillary` writers. //! //! Metadata payloads are surfaced **raw** — `eXIf` bytes, the inflated ICC profile, the XMP -//! packet — precisely so callers can hand them to `gamut_metadata::MetadataBlock` -//! (`Exif`/`Icc`/`Xmp`) without gamut-png depending on the metadata stack. Fixed-layout -//! colour-space chunks (`gAMA`/`cHRM`/`sRGB`/`cICP`) are additionally parsed into values, in the -//! same ×100 000 fixed-point units the encoder accepts. Ancillary payloads are attacker -//! territory: a malformed payload skips that chunk (§13.1) rather than failing the image, and -//! compressed payloads (iCCP/zTXt/iTXt) inflate under one cumulative byte budget so a metadata -//! zlib bomb cannot exhaust memory. +//! packet, the C2PA manifest store — precisely so callers can hand them to +//! `gamut_metadata::MetadataBlock` (`Exif`/`Icc`/`Xmp`/`C2pa`) without gamut-png depending on +//! the metadata stack. Fixed-layout colour-space chunks (`gAMA`/`cHRM`/`sRGB`/`cICP`) are +//! additionally parsed into values, in the same ×100 000 fixed-point units the encoder accepts. +//! Ancillary payloads are attacker territory: a malformed payload skips that chunk (§13.1) +//! rather than failing the image, and compressed payloads (iCCP/zTXt/iTXt) inflate under one +//! cumulative byte budget so a metadata zlib bomb cannot exhaust memory. The manifest store +//! (`caBX`, C2PA 2.4 §A.3.2) is uncompressed but attacker-sized, so its bytes are charged to the +//! same budget rather than copied beside it. use gamut_core::{ Gray8, Gray16, GrayAlpha8, GrayAlpha16, ImageBuf, Indexed8, Rgb8, Rgb16, Rgba8, Rgba16, }; use crate::ancillary::SrgbIntent; +use crate::chunk::CABX; use crate::color::ColorType; use crate::decoder::TransparencyKey; use crate::inflate; @@ -142,6 +145,16 @@ pub struct DecodedPng { /// The XMP packet (the `XML:com.adobe.xmp` iTXt, §11.3.3.2), decompressed if stored /// compressed. Feed as `MetadataBlock::Xmp`. pub xmp: Option>, + /// The C2PA manifest store (the `caBX` chunk, C2PA 2.4 §A.3.2) verbatim: the JUMBF bytes, + /// uncompressed, exactly as the chunk carries them — opaque here, never parsed or judged. + /// Feed as `MetadataBlock::C2pa`. The first `caBX` in the file, and only when it fits the + /// metadata budget; see [`c2pa_duplicates`](Self::c2pa_duplicates). + pub c2pa: Option>, + /// How many further `caBX` chunks followed the first, saturating at 255. A file carries + /// exactly one manifest store — PNG has no multi-chunk store, unlike JPEG's APP11 run — so + /// any value above zero marks a malformed file whose extra stores were ignored rather than + /// concatenated. + pub c2pa_duplicates: u8, /// tEXt/zTXt/iTXt annotations in file order (the XMP packet is excluded). pub texts: Vec, /// gAMA: image gamma × 100 000 (§11.3.2.2) — the unit the encoder's `with_gamma` writes. @@ -165,7 +178,8 @@ pub struct DecodedPng { /// /// Unlike `gamut_jpeg::JpegMetadata` and `gamut_webp::WebpMetadata`, which carry only /// EXIF/XMP/ICC, this also surfaces PNG's parsed colour chunks — `cICP`, `sRGB`, `gAMA`, `cHRM` — -/// because those are uncompressed and answer "what colour space is this?" on their own. +/// because those are uncompressed and answer "what colour space is this?" on their own, and the +/// C2PA manifest store (`caBX`), raw. /// /// Marked `#[non_exhaustive]` so further ancillary chunks can be added without a breaking change. /// @@ -198,6 +212,16 @@ pub struct PngMetadata { /// The XMP packet (the `XML:com.adobe.xmp` iTXt, §11.3.3.2), decompressed if stored /// compressed. Feed as `MetadataBlock::Xmp`. pub xmp: Option>, + /// The C2PA manifest store (the `caBX` chunk, C2PA 2.4 §A.3.2) verbatim and uncompressed — + /// opaque bytes, never parsed or judged. Feed as `MetadataBlock::C2pa`. The first `caBX` in + /// the file, and only when it fits the metadata budget; see + /// [`c2pa_duplicates`](Self::c2pa_duplicates). + pub c2pa: Option>, + /// How many further `caBX` chunks followed the first, saturating at 255. A file carries + /// exactly one manifest store — PNG has no multi-chunk store, unlike JPEG's APP11 run — so + /// any value above zero marks a malformed file whose extra stores were ignored rather than + /// concatenated. + pub c2pa_duplicates: u8, /// tEXt/zTXt/iTXt annotations in file order (the XMP packet is excluded). pub texts: Vec, /// gAMA: image gamma × 100 000 (§11.3.2.2). @@ -211,15 +235,30 @@ pub struct PngMetadata { } /// Parses the metadata-bearing ancillary chunks collected from the stream (in file order). -/// Malformed payloads skip their chunk (§13.1); compressed payloads share `budget` bytes of -/// inflated output, and a payload that would bust the remainder is skipped, not an error. -/// Once-only chunks keep their first occurrence. +/// Malformed payloads skip their chunk (§13.1); compressed payloads — and the uncompressed but +/// attacker-sized `caBX` store — share `budget` bytes of output, and a payload that would bust +/// the remainder is skipped, not an error. Once-only chunks keep their first occurrence; a +/// second `caBX` is additionally counted, since exactly one store is the rule (C2PA §A.3.2). pub(crate) fn collect(chunks: &[([u8; 4], &[u8])], budget: usize) -> PngMetadata { let mut meta = PngMetadata::default(); let mut budget = budget; + // Whether a `caBX` has been seen at all, admitted or not: the first is the store (or is + // skipped for its size), every later one is a duplicate — never promoted into its place. + let mut seen_c2pa = false; for &(chunk_type, data) in chunks { match &chunk_type { b"eXIf" if meta.exif.is_none() => meta.exif = Some(data.to_vec()), + _ if chunk_type == CABX => { + if seen_c2pa { + meta.c2pa_duplicates = meta.c2pa_duplicates.saturating_add(1); + } else { + seen_c2pa = true; + if data.len() <= budget { + budget -= data.len(); + meta.c2pa = Some(data.to_vec()); + } + } + } b"iCCP" if meta.icc_profile.is_none() => { meta.icc_profile = parse_iccp(data, &mut budget); } @@ -520,6 +559,61 @@ mod tests { assert_eq!(meta.cicp.unwrap().color_primaries, 9); } + /// Exactly one store per file: the first `caBX` is the store and every later one is counted, + /// never concatenated onto it and never promoted into its place — PNG has no multi-chunk + /// store, unlike JPEG's APP11 run (C2PA §A.3.2). + #[test] + fn the_first_cabx_is_the_store_and_later_ones_are_counted_not_concatenated() { + let meta = collect( + &[(CABX, b"first store"), (CABX, b"second"), (CABX, b"third")], + 1024, + ); + assert_eq!(meta.c2pa.as_deref(), Some(&b"first store"[..])); + assert_eq!(meta.c2pa_duplicates, 2); + + let single = collect(&[(CABX, b"only")], 1024); + assert_eq!(single.c2pa.as_deref(), Some(&b"only"[..])); + assert_eq!(single.c2pa_duplicates, 0); + assert_eq!(collect(&[], 1024).c2pa, None); + } + + /// The duplicate count saturates rather than wrapping: 256 further stores read as 255, not + /// as none at all. + #[test] + fn the_cabx_duplicate_count_saturates_at_255() { + let chunks: Vec<([u8; 4], &[u8])> = (0..257).map(|_| (CABX, &b"s"[..])).collect(); + let meta = collect(&chunks, 1024); + assert_eq!(meta.c2pa_duplicates, 255); + } + + /// `caBX` is attacker-sized like every other ancillary payload, so it is charged to the one + /// cumulative budget rather than copied beside it: a store exactly the size of the remaining + /// budget is admitted and leaves nothing for a compressed payload after it; one byte larger + /// is skipped — and, skipped, it is still the file's one store, so a smaller `caBX` after it + /// is a duplicate rather than a substitute. + #[test] + fn the_cabx_store_is_charged_to_the_metadata_budget() { + let ztxt: Vec = [b"kw\0\0".to_vec(), deflated(b"x")].concat(); + let store = [7u8; 10]; + let fits = collect(&[(CABX, &store), (*b"zTXt", &ztxt)], 10); + assert_eq!( + fits.c2pa.as_deref(), + Some(&store[..]), + "exactly the budget fits" + ); + assert!( + fits.texts.is_empty(), + "the store consumed the budget, so the zTXt after it is skipped" + ); + + let busts = collect(&[(CABX, &store), (CABX, b"tiny")], 9); + assert_eq!(busts.c2pa, None, "one byte over the budget is skipped"); + assert_eq!( + busts.c2pa_duplicates, 1, + "the skipped store is still the first; the next is a duplicate, not a substitute" + ); + } + #[test] fn chrm_coordinates_map_position_for_position() { // Every byte distinct, so any index-arithmetic slip changes some coordinate. diff --git a/crates/gamut-png/src/decoder.rs b/crates/gamut-png/src/decoder.rs index a1a42942..0f4c7222 100644 --- a/crates/gamut-png/src/decoder.rs +++ b/crates/gamut-png/src/decoder.rs @@ -39,7 +39,8 @@ use crate::{adam7, inflate, pack}; /// `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. +/// Default cumulative cap on metadata payloads — inflated iCCP/zTXt/iTXt plus the raw `caBX` +/// manifest store: 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. const SPEC_MAX_DIMENSION: u32 = i32::MAX as u32; @@ -138,9 +139,10 @@ impl PngDecoder { self } - /// Caps the *cumulative* inflated size of compressed metadata payloads — iCCP, zTXt, and - /// compressed iTXt together (default 16 MiB). Payloads past the budget are skipped, not - /// errors; the typed [`DecodeImage`] path never inflates metadata at all. + /// Caps the *cumulative* size of metadata payloads — the inflated iCCP, zTXt and compressed + /// iTXt, plus the raw C2PA manifest store (`caBX`), which is uncompressed but sized by the + /// file, together (default 16 MiB). Payloads past the budget are skipped, not errors; the + /// typed [`DecodeImage`] path never inflates or copies metadata at all. #[must_use] pub fn with_max_metadata_bytes(mut self, bytes: usize) -> Self { self.max_metadata_bytes = bytes; @@ -388,7 +390,7 @@ impl PngDecoder { /// Decodes a PNG into its native layout together with the ancillary metadata — the rich /// counterpart of the typed [`DecodeImage`] implementations, and the only way to reach the /// palette of an indexed image, the tRNS colour key, and the raw metadata payloads - /// (eXIf/ICC/XMP/text, plus parsed gAMA/cHRM/sRGB/cICP values). + /// (eXIf/ICC/XMP/text and the C2PA manifest store, plus parsed gAMA/cHRM/sRGB/cICP values). /// /// # Errors /// @@ -415,6 +417,8 @@ impl PngDecoder { exif: meta.exif, icc_profile: meta.icc_profile, xmp: meta.xmp, + c2pa: meta.c2pa, + c2pa_duplicates: meta.c2pa_duplicates, texts: meta.texts, gamma: meta.gamma, chromaticities: meta.chromaticities, @@ -610,10 +614,11 @@ fn walk_metadata_chunks(data: &[u8]) -> Result> { /// Reads a PNG's ancillary metadata without decoding any pixels. /// /// Walks the chunk stream, collecting the metadata-bearing ancillary chunks and inflating only -/// the compressed ones (iCCP, zTXt, and compressed iTXt) under one cumulative 16 MiB budget. -/// IDAT is skipped by length, so no pixel data is read, copied, or inflated — which makes this -/// cheap enough for a probe on a large file. `cICP`, `sRGB`, `gAMA` and `cHRM` are uncompressed -/// and cost nothing beyond the walk. +/// the compressed ones (iCCP, zTXt, and compressed iTXt) under one cumulative 16 MiB budget, +/// which the raw C2PA manifest store (`caBX`) is charged to as well. IDAT is skipped by length, +/// so no pixel data is read, copied, or inflated — which makes this cheap enough for a probe on +/// a large file. `cICP`, `sRGB`, `gAMA` and `cHRM` are uncompressed and cost nothing beyond the +/// walk. /// /// The result matches [`PngDecoder::decode`] field for field on the same file. Use /// [`PngDecoder::metadata`] instead if you need a metadata budget other than the default. diff --git a/crates/gamut-png/src/deconstruct.rs b/crates/gamut-png/src/deconstruct.rs index 3ad0bb7e..b6b4fcd5 100644 --- a/crates/gamut-png/src/deconstruct.rs +++ b/crates/gamut-png/src/deconstruct.rs @@ -29,7 +29,7 @@ use std::collections::HashMap; use gamut_core::{Error, Result}; -use crate::chunk::{ChunkReader, RawChunk, SIGNATURE}; +use crate::chunk::{C2paSpan, CABX, ChunkReader, RawChunk, SIGNATURE}; use crate::decoded::PngHeader; use crate::decoder::DEFAULT_MAX_IMAGE_BYTES; use crate::filter::FilterType; @@ -391,6 +391,36 @@ impl PngReport { ) } + /// The C2PA manifest store's carriage: the whole span of the first CRC-valid `caBX` chunk — + /// length, type, payload **and CRC** — which is what a `c2pa.hash.data` assertion must exclude + /// (C2PA 2.4 §18.5.4): the store's bytes change when it is written, the length field when it + /// is resized, and the CRC with either, so a hash that keeps any of them breaks on the store's + /// first update. The store's own bytes are the span's `payload`. `None` when the file carries + /// no such chunk. + /// + /// The first CRC-valid one, so this names the chunk [`PngDecoder::decode`] surfaces as its + /// `c2pa` payload (§13.1 skips a CRC mismatch on both sides). A further `caBX` is a malformed + /// file's duplicate — counted by [`chunk`](Self::chunk)`(b"caBX")` and by the decoder's + /// `c2pa_duplicates`, never merged into the span. + /// + /// [`PngDecoder::decode`]: crate::PngDecoder::decode + #[must_use] + pub fn c2pa(&self) -> Option { + self.segments + .iter() + .find(|segment| { + matches!( + segment.kind, + SegmentKind::Chunk { + chunk_type: CABX, + crc_ok: true, + .. + } + ) + }) + .map(|segment| C2paSpan::of(segment.range.clone())) + } + /// 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 — diff --git a/crates/gamut-png/src/encoder.rs b/crates/gamut-png/src/encoder.rs index 499bf061..244dde38 100644 --- a/crates/gamut-png/src/encoder.rs +++ b/crates/gamut-png/src/encoder.rs @@ -12,7 +12,7 @@ use crate::ancillary::{ Ancillary, PaletteOrigin, PhysicalUnit, SrgbIntent, WrittenHeader, WrittenPalette, }; use crate::backend::{IdatDeflater, IdatInfo, Registry, run_deflaters}; -use crate::chunk::{self, SIGNATURE}; +use crate::chunk::{self, C2paSpan, SIGNATURE}; use crate::color::ColorType; use crate::filter::{self, FilterStrategy, FilterType}; use crate::palette::PngPalette; @@ -42,6 +42,21 @@ const BRUTE_FORCE_STRATEGIES: [FilterStrategy; 7] = [ FilterStrategy::MinBigrams, ]; +/// What [`PngEncoder::encode_with_report`] found in the bytes it wrote. +/// +/// Non-exhaustive: a later revision may report a further region without a breaking change. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub struct PngEncodeReport { + /// Where the C2PA manifest-store chunk landed — the whole `caBX` span a `c2pa.hash.data` + /// exclusion must cover, and the payload a signer fills — when [`with_c2pa`] or + /// [`with_c2pa_reserved`] was set; `None` when neither was. + /// + /// [`with_c2pa`]: PngEncoder::with_c2pa + /// [`with_c2pa_reserved`]: PngEncoder::with_c2pa_reserved + pub c2pa: Option, +} + /// A reusable PNG encoder. #[derive(Debug, Clone)] pub struct PngEncoder { @@ -336,6 +351,76 @@ impl PngEncoder { self } + /// Embeds a C2PA manifest store (the `caBX` chunk, C2PA 2.4 §A.3.2), verbatim and + /// uncompressed, as the last chunk before `IDAT`. + /// + /// `store` is the JUMBF manifest store computed **for this file** by an external signer such + /// as `c2pa-rs`. It is written exactly where [`with_c2pa_reserved`](Self::with_c2pa_reserved) + /// puts a placeholder of the same length, so a store built against a reserved encode drops + /// into the same bytes — see there for the reserve-then-fill flow. The bytes are not parsed + /// or validated: gamut carries the store, `c2pa-rs` judges it. + /// + /// A store is bound to the bytes it was signed over, which is why no gamut re-encode helper — + /// and never the `gamut-metadata` facade — hands one to this setter: a store copied forward + /// into a rewritten file is invalid by construction, and `caBX` is *unsafe to copy* for the + /// same reason. Set only a store computed for the output this encoder is about to write. + /// + /// The last of `with_c2pa` / `with_c2pa_reserved` wins; a file carries exactly one store. + #[must_use] + pub fn with_c2pa(mut self, store: &[u8]) -> Self { + self.ancillary.c2pa = Some(store.to_vec()); + self + } + + /// Reserves `len` bytes for a C2PA manifest store: a `caBX` chunk whose payload is `len` zero + /// bytes, as the last chunk before `IDAT`. + /// + /// The reserve-then-fill flow an external signer needs (C2PA 2.4 §18.5): + /// + /// 1. encode with the reservation, via [`encode_with_report`](Self::encode_with_report), which + /// names the chunk's span; + /// 2. hash the output with that **whole** span excluded — length, type, payload and CRC + /// (§18.5.4) — and have the signer build the store against it; + /// 3. encode again with [`with_c2pa`](Self::with_c2pa) and the finished store of the **same + /// length**. The encoder's output is byte-reproducible and the store is the last chunk + /// before `IDAT`, so the second file differs from the first only inside that span: the + /// payload and the chunk CRC. Every other byte, and every offset, is unchanged. + /// + /// The reservation is `len` bytes exactly — no slack is added — so ask for what the signer + /// says it needs (`c2pa-rs` reports a `reserve_size`). + /// + /// The last of `with_c2pa` / `with_c2pa_reserved` wins; a file carries exactly one store. + #[must_use] + pub fn with_c2pa_reserved(mut self, len: usize) -> Self { + self.ancillary.c2pa = Some(vec![0; len]); + self + } + + /// Encodes `image` as [`EncodeImage::encode_to_vec`] does and reports where the C2PA + /// manifest-store chunk landed, for the reserve-then-fill flow described at + /// [`with_c2pa_reserved`](Self::with_c2pa_reserved). + /// + /// The report is read back from the bytes written — the same walk + /// [`PngReport::c2pa`](crate::PngReport::c2pa) performs — so it cannot disagree with what a + /// later [`deconstruct`](crate::deconstruct) of the same bytes reports, and an indexed image + /// encoded through [`encode_indexed8`](Self::encode_indexed8) gets the same answer from + /// `deconstruct(&png)?.c2pa()`. + /// + /// # Errors + /// + /// As [`EncodeImage::encode_image`]. + pub fn encode_with_report( + &self, + image: ImageRef<'_, P>, + ) -> Result<(Vec, PngEncodeReport)> + where + Self: EncodeImage

, + { + let png = self.encode_to_vec(image)?; + let c2pa = chunk::find_c2pa(&png); + Ok((png, PngEncodeReport { c2pa })) + } + /// Encodes an 8-bit indexed (palette) image. Indexed colour does not fit the single-buffer /// [`EncodeImage`] shape because it needs a separate palette, so it is an inherent method. /// @@ -548,7 +633,8 @@ impl PngEncoder { ) } - /// Shared back end: signature → IHDR → `pre_idat` chunks (e.g. PLTE/tRNS) → filtered + + /// Shared back end: signature → IHDR → `pre_idat` chunks (e.g. PLTE/tRNS) → the remaining + /// ancillary chunks, the C2PA store last → filtered + /// DEFLATE-compressed scanlines as IDAT(s) → IEND. `sample_bytes` is the image in PNG storage /// 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 @@ -574,7 +660,7 @@ impl PngEncoder { // Colour-space chunks precede PLTE. self.ancillary.write_pre_plte(out, self.effort, written); pre_idat(out); // PLTE + tRNS (indexed only) - // Background / physical / timing / text. + // Background / physical / timing / text, and the C2PA store last: immediately before IDAT. self.ancillary.write_post_plte(out, self.effort, written); let idat = self.compress_scanlines( diff --git a/crates/gamut-png/src/lib.rs b/crates/gamut-png/src/lib.rs index b2419d53..74def69f 100644 --- a/crates/gamut-png/src/lib.rs +++ b/crates/gamut-png/src/lib.rs @@ -6,8 +6,9 @@ //! for output sizes on par with the best PNG encoders, trading encode time for size at higher //! levels. The decoder ([`PngDecoder`], issue #249) covers the full still-image spec — every //! colour type and bit depth, Adam7 interlacing, all filters — behind hostile-input limits, and -//! surfaces ancillary metadata (EXIF/ICC/XMP/text) as raw payloads. Animation (APNG) is out of -//! scope. Correctness in both directions is proven differentially against a vendored libpng. +//! surfaces ancillary metadata (EXIF/ICC/XMP/text, and the C2PA manifest store) as raw payloads. +//! Animation (APNG) is out of scope. Correctness in both directions is proven differentially +//! against a vendored libpng. //! //! # Reading metadata without the pixels //! @@ -17,6 +18,19 @@ //! and `gamut_webp::metadata`, and what a colour-space probe should call. //! [`PngDecoder::metadata`] is the same walk with a configurable inflation budget. //! +//! # C2PA manifest store +//! +//! A C2PA manifest store travels in the `caBX` chunk (C2PA 2.4 §A.3.2: ancillary, private, +//! **unsafe to copy**), raw and uncompressed. gamut locates, bounds, carries and reserves it and +//! never judges it: the store is opaque bytes here, and validation is `c2pa-rs`'s. On read it is +//! [`DecodedPng::c2pa`] / [`PngMetadata::c2pa`], the first `caBX` in the file, under the same +//! metadata budget as every other ancillary payload. On write, [`PngEncoder::with_c2pa`] embeds a +//! store computed for this file and [`PngEncoder::with_c2pa_reserved`] reserves its place, as the +//! last chunk before `IDAT`; [`PngEncoder::encode_with_report`] and [`PngReport::c2pa`] name the +//! chunk's **whole** span — length, type, payload and CRC — which is what a `c2pa.hash.data` +//! assertion excludes (§18.5.4), and a reservation is filled by a second encode of equal length +//! that changes no byte outside it. +//! //! # Pluggable IDAT backends //! //! The PNG codestream is the concatenated-IDAT **zlib stream**, and it is where PNG spends its @@ -72,6 +86,7 @@ pub mod stages; pub use abi::{AbiDeflater, AbiInflater, CODEC_ID_ZLIB, PIXEL_FORMAT_FILTERED_BYTES}; pub use ancillary::{PhysicalUnit, SrgbIntent}; pub use backend::{IdatDeflater, IdatInflater, IdatInfo}; +pub use chunk::C2paSpan; pub use color::ColorType; pub use decoded::{ Chromaticities, Cicp, DecodedPng, IccProfile, PngHeader, PngImage, PngMetadata, TextChunk, @@ -81,7 +96,7 @@ pub use deconstruct::{ ChunkStats, DEFAULT_MAX_CHUNKS, DeconstructLimits, FilterHistogram, FilterScan, PassStats, PngReport, Segment, SegmentKind, SkippedFilterScan, deconstruct, deconstruct_with_limits, }; -pub use encoder::PngEncoder; +pub use encoder::{PngEncodeReport, PngEncoder}; pub use filter::{FilterStrategy, FilterType}; /// The DEFLATE compression level, accepted by [`PngEncoder::with_compression`]. pub use gamut_deflate::Level; diff --git a/crates/gamut-png/tests/c2pa.rs b/crates/gamut-png/tests/c2pa.rs new file mode 100644 index 00000000..f655018a --- /dev/null +++ b/crates/gamut-png/tests/c2pa.rs @@ -0,0 +1,445 @@ +//! The C2PA manifest store's carriage in the `caBX` chunk (issue #440; C2PA 2.4 §A.3.2, +//! §18.5.4). Exact-byte: where the encoder puts the chunk, that a reservation is filled without +//! moving a byte outside it, and that both reports name the same whole-chunk span at known +//! offsets. Differential: libpng frames the same payload into the same bytes, decodes gamut's +//! file pixel-exact with the chunk in place, and gamut reads the store back from a libpng-written +//! file. The store is opaque bytes throughout — its behavioural oracle, `c2pa-rs`, is issue +//! #447's. + +mod common; + +use common::{ + chunk, ihdr_payload, libpng_with_extra_chunks, png_from_chunks, sample_bytes, tiny_exif, + tiny_icc_profile, zlib, +}; +use gamut_core::{DecodeImage, Dimensions, EncodeImage, ImageBuf, ImageRef, Indexed8, Rgb8, Rgba8}; +use gamut_png::{ + PhysicalUnit, PngDecoder, PngEncoder, PngPalette, SegmentKind, SrgbIntent, deconstruct, +}; + +/// A stand-in manifest store of `len` bytes: not all zero, no two runs alike, so a fill is +/// visible byte for byte. Opaque here, as every store is. +fn store(len: usize) -> Vec { + (0..len) + .map(|i| (i.wrapping_mul(37) ^ 0x5A) as u8) + .collect() +} + +/// Chunk types in file order (after the signature). +fn chunk_types(png: &[u8]) -> Vec<[u8; 4]> { + let mut types = Vec::new(); + let mut i = 8; + while i + 12 <= png.len() { + let len = u32::from_be_bytes([png[i], png[i + 1], png[i + 2], png[i + 3]]) as usize; + types.push([png[i + 4], png[i + 5], png[i + 6], png[i + 7]]); + i += 12 + len; + } + types +} + +/// The first chunk of type `ty`, framing included: length, type, payload, CRC. +fn framed_chunk(png: &[u8], ty: &[u8; 4]) -> Option> { + let mut i = 8; + while i + 12 <= png.len() { + let len = u32::from_be_bytes([png[i], png[i + 1], png[i + 2], png[i + 3]]) as usize; + if &png[i + 4..i + 8] == ty { + return Some(png[i..i + 12 + len].to_vec()); + } + i += 12 + len; + } + None +} + +/// A 12×9 RGB8 source with enough structure to filter and compress. +fn rgb_source() -> (Vec, Dimensions) { + ( + sample_bytes(12, 9, libpng_oracle::COLOR_RGB, 8, 11), + Dimensions::new(12, 9).expect("valid"), + ) +} + +/// The encoder configured with every other ancillary chunk this crate writes, so the store's +/// placement is asserted against all of them at once. +fn everything_else() -> PngEncoder { + PngEncoder::new() + .with_gamma(1.0 / 2.2) + .with_srgb(SrgbIntent::Perceptual) + .with_chromaticities((0.3127, 0.3290), (0.64, 0.33), (0.30, 0.60), (0.15, 0.06)) + .with_icc_profile("Tiny", &tiny_icc_profile()) + .with_significant_bits(&[8, 8, 8, 8]) + .with_background_rgb(0, 0, 0) + .with_physical_dimensions(2835, 2835, PhysicalUnit::Meter) + .with_time(2026, 9, 6, 1, 2, 3) + .with_text("Title", "placement") + .with_compressed_text("Comment", "zlib body") + .with_international_text("Note", "utf-8") + .with_exif(&tiny_exif()) + .with_xmp("") +} + +/// §A.3.2 asks that `caBX` precede `IDAT`; the encoder puts it *immediately* before the first +/// `IDAT`, after every other ancillary chunk — colour, metadata, text, `PLTE` and `tRNS` — so +/// that nothing whose size could shift the store follows it. Exactly one store, whatever else +/// is set and whichever candidate the auto-reduce race keeps. +#[test] +fn cabx_is_the_chunk_immediately_before_idat_after_every_other_chunk() { + // Few colours with transparency: the palette candidate is in play. + let rgba: Vec = (0..64u8) + .flat_map(|i| { + [ + i % 4 * 60, + 200, + i % 3 * 90, + if i % 5 == 0 { 0 } else { 255 }, + ] + }) + .collect(); + let image = + ImageRef::::new(&rgba, Dimensions::new(8, 8).expect("valid")).expect("image"); + let png = everything_else() + .with_auto_reduce(true) + .with_c2pa(&store(48)) + .encode_to_vec(image) + .expect("encode"); + let types = chunk_types(&png); + let idat = types.iter().position(|t| t == b"IDAT").expect("IDAT"); + assert_eq!(types[idat - 1], *b"caBX", "{types:?}"); + assert_eq!(types.iter().filter(|t| *t == b"caBX").count(), 1); + assert_eq!(types.first(), Some(b"IHDR")); + assert_eq!(types.last(), Some(b"IEND")); + + // The indexed path, whose PLTE and tRNS the caller supplies, orders the same way. + let palette = PngPalette::with_transparency(&[[1, 2, 3], [4, 5, 6]], &[9]).expect("palette"); + let indices = [0u8, 1, 1, 0, 1, 0]; + let image = + ImageRef::::new(&indices, Dimensions::new(3, 2).expect("valid")).expect("image"); + let mut png = Vec::new(); + everything_else() + .with_c2pa(&store(16)) + .encode_indexed8(image, &palette, &mut png) + .expect("encode"); + let types = chunk_types(&png); + let idat = types.iter().position(|t| t == b"IDAT").expect("IDAT"); + assert_eq!(types[idat - 1], *b"caBX", "{types:?}"); + let plte = types.iter().position(|t| t == b"PLTE").expect("PLTE"); + let trns = types.iter().position(|t| t == b"tRNS").expect("tRNS"); + assert!(plte < trns && trns < idat - 1, "{types:?}"); + // No `encode_with_report` on this path: the deconstruct report names the same chunk. + let span = deconstruct(&png) + .expect("deconstruct") + .c2pa() + .expect("span"); + assert_eq!(&png[span.payload], &store(16)[..]); +} + +/// A reservation is a `caBX` whose payload is exactly `len` zero bytes — no slack — and it is +/// byte-identical to embedding `len` explicit zeros. Zero is a length too. +#[test] +fn a_reservation_is_a_zero_payload_of_exactly_the_requested_length() { + let (pixels, dims) = rgb_source(); + let image = ImageRef::::new(&pixels, dims).expect("image"); + let (png, report) = PngEncoder::new() + .with_c2pa_reserved(40) + .encode_with_report(image) + .expect("encode"); + let span = report.c2pa.expect("a reservation is reported"); + assert_eq!(span.payload.len(), 40); + assert_eq!(span.chunk.len(), 40 + 12); + assert!(png[span.payload.clone()].iter().all(|&b| b == 0)); + assert_eq!( + &png[span.chunk.start..span.chunk.start + 4], + &40u32.to_be_bytes() + ); + assert_eq!(&png[span.chunk.start + 4..span.chunk.start + 8], b"caBX"); + + let explicit = PngEncoder::new() + .with_c2pa(&[0; 40]) + .encode_to_vec(image) + .expect("encode"); + assert_eq!(png, explicit, "a reservation is an explicit all-zero store"); + + let (empty, report) = PngEncoder::new() + .with_c2pa_reserved(0) + .encode_with_report(image) + .expect("encode"); + let span = report.c2pa.expect("an empty reservation is still a chunk"); + assert_eq!(span.payload.len(), 0); + assert_eq!(&empty[span.chunk.clone()][..8], b"\0\0\0\0caBX"); +} + +/// The reserve-then-fill contract: encoding again with a store of the reserved length changes +/// **only** bytes inside the chunk's span — the payload and its CRC — and not the length, the +/// type, or any byte before or after the chunk. Two different equal-length stores likewise +/// differ only there. This is what makes a hash computed over the reserved file, with the span +/// excluded (§18.5.4), still hold over the filled one. +#[test] +fn filling_a_reservation_changes_only_the_chunk_span() { + let (pixels, dims) = rgb_source(); + let image = ImageRef::::new(&pixels, dims).expect("image"); + let (reserved, report) = everything_else() + .with_c2pa_reserved(64) + .encode_with_report(image) + .expect("encode"); + let span = report.c2pa.expect("span"); + let first = store(64); + let second: Vec = first.iter().map(|b| !b).collect(); + let filled = everything_else() + .with_c2pa(&first) + .encode_to_vec(image) + .expect("encode"); + let refilled = everything_else() + .with_c2pa(&second) + .encode_to_vec(image) + .expect("encode"); + + for (label, a, b) in [ + ("reserved vs filled", &reserved, &filled), + ("filled vs refilled", &filled, &refilled), + ] { + assert_eq!(a.len(), b.len(), "{label}: equal lengths"); + let differing: Vec = (0..a.len()).filter(|&i| a[i] != b[i]).collect(); + assert!(!differing.is_empty(), "{label}: the stores differ"); + assert!( + differing.iter().all(|i| span.chunk.contains(i)), + "{label}: bytes outside the caBX span changed at {:?}", + differing + .iter() + .filter(|i| !span.chunk.contains(i)) + .collect::>() + ); + assert_eq!( + a[span.chunk.start..span.chunk.start + 8], + b[span.chunk.start..span.chunk.start + 8], + "{label}: length and type are unchanged" + ); + assert_ne!( + a[span.chunk.end - 4..span.chunk.end], + b[span.chunk.end - 4..span.chunk.end], + "{label}: the CRC follows the payload" + ); + } + assert_eq!(&filled[span.payload.clone()], &first[..]); + assert_eq!(&refilled[span.payload.clone()], &second[..]); + // The filled file's own report names the very same span. + assert_eq!( + deconstruct(&filled).expect("deconstruct").c2pa(), + Some(span) + ); +} + +/// The exclusion span is the **whole** chunk — length, type, payload and CRC — at offsets a +/// reader can compute by hand: after the signature (8) and the framed IHDR (25), a 7-byte store +/// occupies `33..52` with its payload at `41..48`. The span is one of the report's claimed +/// segments, and the payload it brackets is what the decoder surfaces. +#[test] +fn the_exclusion_span_is_the_whole_chunk_at_known_offsets() { + let png = png_from_chunks(&[ + chunk(b"IHDR", &ihdr_payload(3, 2, 8, 2, 0)), + chunk(b"caBX", b"jumbf!!"), + chunk(b"IDAT", &zlib(&[0u8; 20])), + chunk(b"IEND", &[]), + ]); + let report = deconstruct(&png).expect("deconstruct"); + let span = report.c2pa().expect("caBX"); + assert_eq!(span.chunk, 33..52); + assert_eq!(span.payload, 41..48); + assert_eq!( + &png[span.chunk.start..span.chunk.start + 4], + &7u32.to_be_bytes() + ); + assert_eq!(&png[span.chunk.start + 4..span.chunk.start + 8], b"caBX"); + assert_eq!(&png[span.payload.clone()], b"jumbf!!"); + assert!( + report.segments.iter().any(|s| s.range == span.chunk + && matches!( + s.kind, + SegmentKind::Chunk { + chunk_type: [b'c', b'a', b'B', b'X'], + payload_len: 7, + crc_ok: true, + } + )), + "the span is a claimed segment: {:?}", + report.segments + ); + assert_eq!( + gamut_png::metadata(&png).expect("metadata").c2pa.as_deref(), + Some(&b"jumbf!!"[..]) + ); +} + +/// Neither setter set: no chunk, no span from either report, nothing surfaced on decode. +#[test] +fn without_a_store_there_is_no_chunk_and_no_span() { + let (pixels, dims) = rgb_source(); + let image = ImageRef::::new(&pixels, dims).expect("image"); + let (png, report) = everything_else().encode_with_report(image).expect("encode"); + assert_eq!(report.c2pa, None); + assert!(!chunk_types(&png).contains(b"caBX")); + assert_eq!(deconstruct(&png).expect("deconstruct").c2pa(), None); + let decoded = PngDecoder::new().decode(&png).expect("decode"); + assert_eq!(decoded.c2pa, None); + assert_eq!(decoded.c2pa_duplicates, 0); +} + +/// Both read entry points surface the store byte for byte, and the last of the two setters +/// wins — a file carries exactly one store. +#[test] +fn decode_and_metadata_surface_the_store_verbatim_and_the_last_setter_wins() { + let (pixels, dims) = rgb_source(); + let image = ImageRef::::new(&pixels, dims).expect("image"); + let store = store(300); + let png = PngEncoder::new() + .with_c2pa(&store) + .encode_to_vec(image) + .expect("encode"); + let meta = gamut_png::metadata(&png).expect("metadata"); + assert_eq!(meta.c2pa.as_deref(), Some(&store[..])); + assert_eq!(meta.c2pa_duplicates, 0); + let decoded = PngDecoder::new().decode(&png).expect("decode"); + assert_eq!(decoded.c2pa, meta.c2pa); + assert_eq!(decoded.c2pa_duplicates, 0); + + let reserved_last = PngEncoder::new() + .with_c2pa(&store) + .with_c2pa_reserved(5) + .encode_to_vec(image) + .expect("encode"); + assert_eq!( + gamut_png::metadata(&reserved_last).expect("metadata").c2pa, + Some(vec![0; 5]) + ); + let store_last = PngEncoder::new() + .with_c2pa_reserved(5) + .with_c2pa(&store) + .encode_to_vec(image) + .expect("encode"); + assert_eq!( + gamut_png::metadata(&store_last).expect("metadata").c2pa, + Some(store) + ); +} + +/// Exactly one store per file: the first `caBX` is the store, a second is counted, never +/// concatenated onto the first and never merged into its span. +#[test] +fn the_first_store_wins_and_a_second_is_counted_not_merged() { + let png = png_from_chunks(&[ + chunk(b"IHDR", &ihdr_payload(3, 2, 8, 2, 0)), + chunk(b"caBX", b"first"), + chunk(b"caBX", b"second"), + chunk(b"IDAT", &zlib(&[0u8; 20])), + chunk(b"IEND", &[]), + ]); + let meta = gamut_png::metadata(&png).expect("metadata"); + assert_eq!(meta.c2pa.as_deref(), Some(&b"first"[..])); + assert_eq!(meta.c2pa_duplicates, 1); + let decoded = PngDecoder::new().decode(&png).expect("decode"); + assert_eq!(decoded.c2pa.as_deref(), Some(&b"first"[..])); + assert_eq!(decoded.c2pa_duplicates, 1); + + let report = deconstruct(&png).expect("deconstruct"); + let span = report.c2pa().expect("span"); + assert_eq!(span.chunk, 33..50, "the first chunk, 5 + 12 bytes"); + assert_eq!(&png[span.payload], b"first"); + assert_eq!(report.chunk(b"caBX").expect("stats").count, 2); +} + +/// A `caBX` whose CRC does not match is skipped on decode (§13.1) — it is not the store and +/// it is not a duplicate either, since it never reaches the metadata pass — and the exclusion +/// span names the CRC-valid store the decoder actually surfaces, not the damaged bytes before +/// it. The damage is still visible: the report accounts both chunks and is not intact. +#[test] +fn a_cabx_with_a_bad_crc_is_neither_the_store_nor_the_exclusion_span() { + let mut damaged = chunk(b"caBX", b"corrupt"); + let last = damaged.len() - 1; + damaged[last] ^= 0xFF; + let png = png_from_chunks(&[ + chunk(b"IHDR", &ihdr_payload(3, 2, 8, 2, 0)), + damaged, + chunk(b"caBX", b"valid"), + chunk(b"IDAT", &zlib(&[0u8; 20])), + chunk(b"IEND", &[]), + ]); + let meta = gamut_png::metadata(&png).expect("metadata"); + assert_eq!(meta.c2pa.as_deref(), Some(&b"valid"[..])); + assert_eq!(meta.c2pa_duplicates, 0); + + let report = deconstruct(&png).expect("deconstruct"); + let span = report.c2pa().expect("the valid store"); + assert_eq!(&png[span.payload], b"valid"); + assert_eq!(span.chunk.start, 33 + 12 + 7, "after the damaged chunk"); + assert_eq!(report.chunk(b"caBX").expect("stats").count, 2); + assert!(!report.is_intact()); +} + +/// The store is attacker-sized like every ancillary payload, so it is charged to the decoder's +/// cumulative metadata budget: a budget of exactly its length admits it, one byte less skips +/// it — without error, and without touching the pixels. +#[test] +fn a_store_past_the_metadata_budget_is_skipped_not_an_error() { + let (pixels, dims) = rgb_source(); + let image = ImageRef::::new(&pixels, dims).expect("image"); + let store = store(1000); + let png = PngEncoder::new() + .with_c2pa(&store) + .encode_to_vec(image) + .expect("encode"); + + let exact = PngDecoder::new().with_max_metadata_bytes(1000); + assert_eq!( + exact.metadata(&png).expect("metadata").c2pa.as_deref(), + Some(&store[..]) + ); + let tight = PngDecoder::new().with_max_metadata_bytes(999); + assert_eq!(tight.metadata(&png).expect("metadata").c2pa, None); + let decoded = tight.decode(&png).expect("decode still succeeds"); + assert_eq!(decoded.c2pa, None); + let typed: ImageBuf = tight.decode_image(&png).expect("typed decode"); + assert_eq!(typed.as_samples(), pixels); +} + +/// The libpng oracle. libpng has no C2PA support and carries `caBX` as an unknown chunk, which +/// is exactly what proves the framing: for the same payload it must produce the same twelve +/// framing bytes — length, type and CRC — around the same store, or one of the two is wrong +/// about §5.3. It then decodes gamut's file pixel-exact with the chunk in place, and gamut reads +/// the store back from libpng's file, where libpng frames unknown chunks right after IHDR. +#[test] +fn gamut_frames_cabx_byte_for_byte_as_libpng_does() { + let (pixels, dims) = rgb_source(); + let image = ImageRef::::new(&pixels, dims).expect("image"); + let store = store(77); + let gamut = PngEncoder::new() + .with_c2pa(&store) + .encode_to_vec(image) + .expect("encode"); + let reference = libpng_with_extra_chunks(12, 9, &[(*b"caBX", &store)]); + + let ours = framed_chunk(&gamut, b"caBX").expect("gamut wrote the chunk"); + let theirs = framed_chunk(&reference, b"caBX").expect("libpng wrote the chunk"); + assert_eq!( + ours, theirs, + "length, type, payload and CRC agree with libpng" + ); + assert_eq!(ours.len(), 12 + 77); + + let decoded = libpng_oracle::decode(&gamut); + assert_eq!((decoded.width, decoded.height), (12, 9)); + assert_eq!(decoded.pixels, pixels, "libpng decodes past the chunk"); + + let meta = gamut_png::metadata(&reference).expect("metadata"); + assert_eq!(meta.c2pa.as_deref(), Some(&store[..])); + let span = deconstruct(&reference) + .expect("deconstruct") + .c2pa() + .expect("span"); + assert_eq!( + span.chunk, + 33..33 + 12 + 77, + "libpng put it right after IHDR" + ); + let typed: ImageBuf = PngDecoder::new() + .decode_image(&reference) + .expect("gamut decodes libpng's file"); + assert_eq!(typed.as_samples(), pixels); +} diff --git a/crates/gamut-png/tests/metadata.rs b/crates/gamut-png/tests/metadata.rs index 01e76200..c1b10acc 100644 --- a/crates/gamut-png/tests/metadata.rs +++ b/crates/gamut-png/tests/metadata.rs @@ -40,10 +40,12 @@ fn every_carrier_round_trips_byte_exact() { let exif = tiny_exif(); let icc = tiny_icc_profile(); let xmp = ""; + let c2pa = b"\0\0\0\x14jumbopaque store"; let png = encode(|e| { e.with_exif(&exif) .with_icc_profile("Tiny", &icc) .with_xmp(xmp) + .with_c2pa(c2pa) .with_text("Author", "nobody") .with_compressed_text("Comment", "compressed comment") .with_international_text("Title", "international title") @@ -63,6 +65,7 @@ fn every_carrier_round_trips_byte_exact() { assert_eq!(profile.name, "Tiny"); assert_eq!(profile.profile, icc); assert_eq!(meta.xmp.as_deref(), Some(xmp.as_bytes())); + assert_eq!(meta.c2pa.as_deref(), Some(&c2pa[..])); assert_eq!(meta.gamma, Some(45_455)); assert_eq!(meta.srgb, Some(SrgbIntent::RelativeColorimetric)); let chrm = meta.chromaticities.expect("cHRM present"); @@ -87,6 +90,7 @@ fn metadata_agrees_with_decode_field_for_field() { e.with_exif(&exif) .with_icc_profile("Tiny", &icc) .with_xmp("") + .with_c2pa(b"\0\0\0\x10jumbc2pa") .with_text("Author", "nobody") .with_gamma(1.0 / 2.2) .with_srgb(SrgbIntent::Perceptual) @@ -98,6 +102,8 @@ fn metadata_agrees_with_decode_field_for_field() { assert_eq!(meta.exif, decoded.exif); assert_eq!(meta.icc_profile, decoded.icc_profile); assert_eq!(meta.xmp, decoded.xmp); + assert_eq!(meta.c2pa, decoded.c2pa); + assert_eq!(meta.c2pa_duplicates, decoded.c2pa_duplicates); assert_eq!(meta.texts, decoded.texts); assert_eq!(meta.gamma, decoded.gamma); assert_eq!(meta.chromaticities, decoded.chromaticities); From deb28c715a98012d342f49c92bf699740cb5958f Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Sun, 6 Sep 2026 13:43:39 -0400 Subject: [PATCH 56/94] fix(png): race the chunk-free reduction the raw estimate eliminated `reduce::analyze8` collapsed five reduction candidates to one by raw estimated size, and `write_reduced_or_native` then raced only that single winner against the fully unreduced encoding. So whenever a palette won on raw bytes and lost the finished file to `PLTE`'s incompressible payload, the runner-up it had eliminated -- an alpha drop, a greyscale collapse, `analyze16`'s 16->8 demotion -- was never encoded at all and the encoder fell all the way back to no reduction. Measured at this revision: a 128x128 opaque RGBA image with 256 colours emitted 349 bytes with an alpha channel that was 255 everywhere, against 317 for the same pixels as RGB; a 64x64 RGB16 image whose every sample is `k*257` emitted 220 bytes at depth 16, against 172 for the plain demotion. `analyze8`/`analyze16` now return a `Reductions` that names the family of the estimate's winner, and hands over the best chunk-free candidate alongside a chunk-carrying one. `write_reduced_or_native` races all three -- chunk-carrying, chunk-free, unreduced -- and keeps the smallest. Ties resolve toward the earlier of `chunked > chunk-free > native`: the existing `prefers_native` tie-break (a tie keeps the palette) is unchanged, and the new `prefers_chunk_free` keeps the candidate the estimate ranked first, so an equal-length runner-up changes no output. A chunk-free *winner* still needs no race and is written straight out; only a chunk-free *runner-up* is measured, because the candidate that beat it carries a chunk. The corpus had no opaque-RGBA-with-few-colours row and no 16-bit row, which is why no gate could see this. `opaque256_rgba8` and `demotable_rgb16` are those two cases, budgeted against libpng-9 at 0.78 and 0.68 against measured 0.741 and 0.644; both would breach their budgets at the pre-fix sizes. STATUS.md's axis 3 said "done" and the cost model said "never worse than either candidate alone". Neither was true of the selection, so axis 3 is now **partial** -- what remains is the pair that both carry a chunk, a palette and a `tRNS` colour key, still resolved by the raw estimate alone -- and the worst-case pass count is 7 x 3 x 2 = 42, not 28. --- crates/gamut-png/STATUS.md | 37 +- crates/gamut-png/src/encoder.rs | 150 +++++--- crates/gamut-png/src/reduce.rs | 439 ++++++++++++++++++------ crates/gamut-png/tests/common/corpus.rs | 57 +++ crates/gamut-png/tests/size_contract.rs | 118 ++++++- 5 files changed, 604 insertions(+), 197 deletions(-) diff --git a/crates/gamut-png/STATUS.md b/crates/gamut-png/STATUS.md index 8e72d11e..a099f2d4 100644 --- a/crates/gamut-png/STATUS.md +++ b/crates/gamut-png/STATUS.md @@ -133,7 +133,7 @@ byte) plus removing a sixth redundant filter pass per scanline. | --- | --- | --- | | 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. | +| 3 | Smallest lawful representation | **partial** — every reduction is implemented (grey, alpha-drop, ≤256 palette, 16→8, sub-byte, and a `tRNS` colour key for grey/truecolour) and 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. What is not done is the **selection**. `reduce::analyze8` still resolves *some* candidates on the raw estimate alone, and a raw estimate cannot see DEFLATE (below). Until the three-candidate race below it resolved all of them, and the eliminated runner-up was often the one that won the finished file: an opaque RGBA image with ≤256 colours kept an alpha channel that was 255 everywhere (349 bytes against 317), and a 16-bit image whose samples are all `k·257` kept all sixteen bits (220 against 172). The estimate now hands the best **chunk-free** candidate over beside the chunk-carrying one and `write_reduced_or_native` measures both, which closes that whole family — the chunk-free gates are mutually exclusive, so at most one such candidate ever exists. The remainder is the *pair* that both carry a chunk: where a palette and a `tRNS` colour key are both lawful, only the raw-smaller one is ever encoded. | | 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. 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] | @@ -161,20 +161,33 @@ The raw-size estimate sees 16 664 against 65 536 and picks the palette by 4× ** 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 +`write_reduced_or_native` encodes the candidates and keeps the smallest, 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. +any candidate it encoded. The three declined rows are the evidence: had the estimate been trusted, +each would have carried a palette and been larger. + +**Three candidates, not two.** "Never worse than any candidate it encoded" is only worth +having if the candidates that could win are among them, and for a while they were not. The +estimate collapsed five reductions to one winner and only that winner was raced, so on an image +where the palette won the estimate the reductions it beat — the alpha drop, the greyscale +collapse, the 16→8 demotion — were never encoded, and losing the race dropped the file all the way +back to *no* reduction. `reduce::Reductions` therefore carries the best chunk-free candidate beside +the chunk-carrying one, and the race is over three encodings: chunk-carrying, chunk-free, +unreduced. Ties resolve toward the earlier of `chunked ≻ chunk-free ≻ native` — the more reduced +encoding, and among equal-length files the one already emitted, so a tie changes no output. + +A chunk-free *winner* still pays for nothing: it adds nothing DEFLATE cannot compress, so the raw +comparison that chose it is sound and it is written straight out. It is a chunk-free *runner-up* +that has to be measured, because the candidate that beat it does carry a chunk. **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 +tries seven whole-image strategies, `write_reduced_or_native` encodes up to three 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 cleanable, palettisable or keyable, *and* has a chunk-free reduction available — is +therefore 7 × 3 × 2 = **42** 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 diff --git a/crates/gamut-png/src/encoder.rs b/crates/gamut-png/src/encoder.rs index 499bf061..89b4e5a7 100644 --- a/crates/gamut-png/src/encoder.rs +++ b/crates/gamut-png/src/encoder.rs @@ -16,7 +16,7 @@ use crate::chunk::{self, SIGNATURE}; use crate::color::ColorType; use crate::filter::{self, FilterStrategy, FilterType}; use crate::palette::PngPalette; -use crate::reduce::{self, Reduced}; +use crate::reduce::{self, Reduced, Reductions}; use crate::{ihdr, pack}; /// IDAT payload cap. A decoder concatenates consecutive IDATs, so the split is transparent; a @@ -421,12 +421,10 @@ impl PngEncoder { color: ColorType, out: &mut Vec, ) -> Result { - if self.auto_reduce - && let Some(reduced) = reduce::analyze8(samples, channels) - { + if self.auto_reduce { return self.write_reduced_or_native( dims, - reduced, + reduce::analyze8(samples, channels), |o| { self.write_png( (dims.width, dims.height), @@ -457,12 +455,10 @@ impl PngEncoder { color: ColorType, out: &mut Vec, ) -> Result { - if self.auto_reduce - && let Some(reduced) = reduce::analyze16(samples, channels) - { + if self.auto_reduce { return self.write_reduced_or_native( dims, - reduced, + reduce::analyze16(samples, channels), |o| self.encode_16bit(dims, samples, color, o), out, ); @@ -636,48 +632,73 @@ 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. + /// Writes the smallest of the encodings [`reduce::analyze8`] / [`reduce::analyze16`] made + /// reachable: the reduction they ranked first, the best reduction that adds no chunk, and the + /// image encoded untouched. /// - /// [`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. + /// The analysis 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. + /// Rather than guess a correction factor, the candidates are encoded and the smallest kept. + /// That is exactly what [`FilterStrategy::BruteForce`] already does for filters, and it needs + /// no tuned constant. /// - /// 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. + /// **Three candidates, not two.** The raw estimate collapses five reductions to one winner, + /// and when that winner is a palette the runner-up it eliminated is often a chunk-free + /// reduction — an alpha drop, a greyscale collapse, a 16→8 demotion — that *would* have won + /// the finished file. Racing only the palette against the unreduced image threw those away + /// and fell all the way back to no reduction at all: a 128x128 opaque RGBA image with 256 + /// colours kept an alpha channel that was 255 everywhere (349 bytes against 317), and a 64x64 + /// 16-bit image whose samples are all `k·257` kept all sixteen bits (220 against 172). So + /// [`Reductions`] hands over the best chunk-free candidate beside the chunk-carrying one, and + /// all three are measured — `tests/size_contract.rs`'s `opaque256_rgba8` and + /// `demotable_rgb16` rows are those two cases. + /// + /// **The total order.** Ties resolve toward the earlier of `chunked ≻ chunk-free ≻ native` — + /// the more reduced encoding, and, among equal-length files, the one the encoder already + /// emitted before the runner-up joined the race, so a tie changes no output. See + /// [`prefers_chunk_free`] and [`prefers_native`], where each step is stated on its own. + /// + /// Only a reduction that *carries a chunk* pays for the extra encodes — a palette's `PLTE` + /// (+ `tRNS`), or a colour key's `tRNS`. A chunk-free winner adds nothing DEFLATE cannot + /// compress, so the raw comparison that chose it is sound and it is written immediately; + /// that case is [`Reductions::ChunkFree`], and the analysis, not this function, decides it. fn write_reduced_or_native( &self, dims: Dimensions, - reduced: Reduced, + reductions: Reductions, native: impl FnOnce(&mut Vec) -> Result, out: &mut Vec, ) -> Result { - let carries_chunks = matches!( - reduced, - Reduced::Indexed { .. } | Reduced::Rgb8Keyed { .. } | Reduced::GrayKeyed { .. } - ); - if !carries_chunks { - return self.write_reduced(dims, reduced, out); + let (chunked, chunk_free) = match reductions { + Reductions::None => return native(out), + Reductions::ChunkFree(reduced) => return self.write_reduced(dims, reduced, out), + Reductions::Chunked { + chunked, + chunk_free, + } => (chunked, chunk_free), + }; + let mut reduced_encoding = Vec::new(); + self.write_reduced(dims, chunked, &mut reduced_encoding)?; + if let Some(free) = chunk_free { + let mut free_encoding = Vec::new(); + self.write_reduced(dims, free, &mut free_encoding)?; + if prefers_chunk_free(free_encoding.len(), reduced_encoding.len()) { + reduced_encoding = free_encoding; + } } - 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 prefers_native(native_encoding.len(), palette_encoding.len()) { + let winner = if prefers_native(native_encoding.len(), reduced_encoding.len()) { native_encoding } else { - palette_encoding + reduced_encoding }; out.extend_from_slice(&winner); Ok(winner.len()) @@ -828,11 +849,23 @@ 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`]. +/// Whether the chunk-free reduction beats the chunk-carrying one, the first step of +/// [`PngEncoder::write_reduced_or_native`]'s three-way race. +/// +/// **A tie keeps the chunk-carrying encoding**: it is the candidate the raw estimate ranked first +/// and the one the encoder emitted before the runner-up joined the race, so an equal-length +/// runner-up changes no output. Split out for the same reason as [`prefers_native`]. +fn prefers_chunk_free(chunk_free_len: usize, chunked_len: usize) -> bool { + chunk_free_len < chunked_len +} + +/// Whether the unreduced encoding beats the winning reduction, 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. +/// **A tie keeps the reduction**, which decodes with less work for the same bytes — and where the +/// palette won the first step, a tie here keeps the palette. 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 } @@ -881,12 +914,10 @@ fn write_idat(out: &mut Vec, zlib_stream: &[u8]) { // CMYK has no PNG colour type. impl EncodeImage for PngEncoder { fn encode_image(&self, image: ImageRef<'_, Gray8>, out: &mut Vec) -> Result { - if self.auto_reduce - && let Some(reduced) = reduce::analyze8(image.as_samples(), 1) - { + if self.auto_reduce { return self.write_reduced_or_native( image.dimensions(), - reduced, + reduce::analyze8(image.as_samples(), 1), |o| self.encode_8bit(image, ColorType::Grayscale, o), out, ); @@ -915,12 +946,10 @@ impl EncodeImage for PngEncoder { } impl EncodeImage for PngEncoder { fn encode_image(&self, image: ImageRef<'_, Rgb8>, out: &mut Vec) -> Result { - if self.auto_reduce - && let Some(reduced) = reduce::analyze8(image.as_samples(), 3) - { + if self.auto_reduce { return self.write_reduced_or_native( image.dimensions(), - reduced, + reduce::analyze8(image.as_samples(), 3), |o| self.encode_8bit(image, ColorType::Truecolor, o), out, ); @@ -959,12 +988,10 @@ 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(samples, 1) - { + if self.auto_reduce { return self.write_reduced_or_native( dims, - reduced, + reduce::analyze16(samples, 1), |o| self.encode_16bit(dims, samples, ColorType::Grayscale, o), out, ); @@ -975,12 +1002,10 @@ impl EncodeImage for PngEncoder { 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(samples, 3) - { + if self.auto_reduce { return self.write_reduced_or_native( dims, - reduced, + reduce::analyze16(samples, 3), |o| self.encode_16bit(dims, samples, ColorType::Truecolor, o), out, ); @@ -1167,6 +1192,19 @@ mod tests { assert!(!prefers_native(10, 10), "a tie keeps the palette"); } + #[test] + fn a_tie_between_the_chunk_free_runner_up_and_the_palette_keeps_the_palette() { + assert!( + prefers_chunk_free(10, 11), + "a smaller chunk-free reduction wins" + ); + assert!(!prefers_chunk_free(11, 10), "a smaller palette wins"); + assert!( + !prefers_chunk_free(10, 10), + "a tie keeps the chunk-carrying encoding the estimate ranked first" + ); + } + #[test] fn a_tie_between_cleaned_and_plain_keeps_the_cleaned_encoding() { assert!(prefers_plain(10, 11), "smaller plain wins"); diff --git a/crates/gamut-png/src/reduce.rs b/crates/gamut-png/src/reduce.rs index 98a76c8d..6bfef424 100644 --- a/crates/gamut-png/src/reduce.rs +++ b/crates/gamut-png/src/reduce.rs @@ -3,10 +3,17 @@ //! Before encoding, an image is scanned for redundancy that a smaller PNG encoding can drop without //! changing any pixel: an all-opaque alpha channel, identical R=G=B channels, a palette of ≤256 //! distinct colours, grey values exactly representable at a sub-byte depth (§13.12), or 16-bit -//! samples whose high and low bytes agree (lossless 16→8 demotion). The smallest *estimated* -//! encoding (by raw byte count; sub-byte row padding is ignored, as in the palette estimate) is -//! chosen; the actual DEFLATE pass then compresses it. Every reduction is exactly reversible, so -//! the decoded pixels are unchanged — the libpng oracle verifies this. +//! samples whose high and low bytes agree (lossless 16→8 demotion). Every reduction is exactly +//! reversible, so the decoded pixels are unchanged — the libpng oracle verifies this. +//! +//! The analysis ranks the candidates by *estimated* raw byte count (sub-byte row padding is +//! ignored, as in the palette estimate) and returns a [`Reductions`] rather than a single winner, +//! because a raw byte count cannot see DEFLATE. A palette or a colour key carries a `PLTE`/`tRNS` +//! chunk that DEFLATE cannot touch, so the estimate's winner can lose the finished file to a +//! candidate it beat on raw bytes. Whenever the estimate picks a chunk-carrying candidate the +//! best *chunk-free* one is handed over beside it, and +//! [`PngEncoder`](crate::PngEncoder) encodes both — and the unreduced image — and keeps the +//! smallest. use std::collections::HashMap; use std::collections::hash_map::Entry; @@ -66,6 +73,33 @@ pub enum Reduced { }, } +/// What [`analyze8`] / [`analyze16`] offer the encoder for one image. +/// +/// The estimate that ranks the candidates counts *raw* bytes, which does not predict compressed +/// size: a palette's `PLTE` (and often `tRNS`) and a colour key's `tRNS` are flat, incompressible +/// costs, while the samples they replace may compress by two orders of magnitude. So a +/// chunk-carrying winner is never trusted on its own — it arrives with the best candidate that +/// carries no chunk, and [`PngEncoder`](crate::PngEncoder) encodes both plus the unreduced image +/// and keeps the smallest file. +/// +/// A chunk-free winner needs no such company: it adds nothing DEFLATE cannot compress, so the raw +/// comparison that chose it is sound and it is written directly. +pub enum Reductions { + /// No reduction stores fewer bytes than the input layout: encode the image as it arrived. + None, + /// The estimate's winner adds no chunk, so it is the whole answer. + ChunkFree(Reduced), + /// The estimate's winner carries a chunk, so it must be raced. + Chunked { + /// The chunk-carrying candidate the estimate ranked smallest. + chunked: Reduced, + /// The smallest candidate that adds no chunk, or `None` when no chunk-free reduction + /// stores fewer bytes than the input layout — the identity cases, where the "reduction" + /// re-spells the input and racing it would compress the same samples twice. + chunk_free: Option, + }, +} + /// The smallest indexed bit depth (1, 2, 4, or 8) that can address `palette_len` entries. pub(crate) fn index_bit_depth(palette_len: usize) -> u8 { match palette_len { @@ -218,9 +252,9 @@ fn colour_key(pixels: &[u8], channels: usize) -> Option<[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 fn analyze8(pixels: &[u8], channels: usize) -> Option { +/// and returns the lossless reductions that beat the input encoding — see [`Reductions`] for why +/// that is one candidate or two rather than always one. +pub fn analyze8(pixels: &[u8], channels: usize) -> Reductions { debug_assert!((1..=4).contains(&channels)); let pixel_count = pixels.len() / channels; @@ -294,55 +328,85 @@ pub fn analyze8(pixels: &[u8], channels: usize) -> Option { .min(rgb_size) .min(keyed_size); if best >= input_size { - return None; // no reduction is smaller + return Reductions::None; // no reduction is smaller } - if best == gray_size { - let scale = gray8_scale(gray_depth); - Some(Reduced::Gray { - depth: gray_depth, - samples: pixels - .chunks_exact(channels) - .map(|px| px[0] / scale) - .collect(), - }) - } else if best == gray_alpha_size { - let mut out = Vec::with_capacity(pixel_count * 2); - for px in pixels.chunks_exact(channels) { - let key = pixel_key(px, channels); - out.push(key[0]); - out.push(key[3]); - } - Some(Reduced::GrayAlpha8(out)) - } else if let Some(key) = key - && best == keyed_size - { - if all_gray { - Some(Reduced::GrayKeyed { + // The chunk-free family: greyscale, grey+alpha, alpha drop. Their three gates are mutually + // exclusive -- grey needs `all_gray && all_opaque`, grey+alpha `all_gray && !all_opaque`, the + // alpha drop `all_opaque && !all_gray` -- so `free_size` names whichever one applies rather + // than resolving a race between them. + // + // `< input_size`, not `<=`: at equality the "reduction" is the input layout re-spelled (a + // Gray8 input reduced to 8-bit grey, a GrayAlpha8 input to grey+alpha), so offering it as a + // runner-up would make the encoder compress the same samples a second time for a file it + // already has. + let free_size = gray_size.min(gray_alpha_size).min(rgb_size); + let chunk_free = (free_size < input_size).then(|| { + if free_size == gray_size { + let scale = gray8_scale(gray_depth); + Reduced::Gray { + depth: gray_depth, samples: pixels .chunks_exact(channels) - .map(|px| pixel_key(px, channels)[0]) + .map(|px| px[0] / scale) .collect(), - key: key[0], - }) + } + } else if free_size == gray_alpha_size { + let mut out = Vec::with_capacity(pixel_count * 2); + for px in pixels.chunks_exact(channels) { + let key = pixel_key(px, channels); + out.push(key[0]); + out.push(key[3]); + } + Reduced::GrayAlpha8(out) } 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]); + out.extend_from_slice(&px[0..3]); } - Some(Reduced::Rgb8Keyed { - samples: out, - key: [key[0], key[1], key[2]], - }) + Reduced::Rgb8(out) } - } else if best == rgb_size { - let mut out = Vec::with_capacity(pixel_count * 3); - for px in pixels.chunks_exact(channels) { - out.extend_from_slice(&px[0..3]); + }); + + // The chunk-carrying family: a colour key's `tRNS`, or a palette's `PLTE` (+ `tRNS`). Built + // only if one of them is the estimate's winner, since building either walks the pixels again. + let chunk_carrying = || { + if let Some(key) = key + && best == keyed_size + { + if all_gray { + 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]); + } + Reduced::Rgb8Keyed { + samples: out, + key: [key[0], key[1], key[2]], + } + } + } else { + build_indexed(pixels, channels, &palette, &palette_index) } - Some(Reduced::Rgb8(out)) - } else { - Some(build_indexed(pixels, channels, &palette, &palette_index)) + }; + + match chunk_free { + // The estimate's own winner is the chunk-free candidate, so nothing carries a chunk and + // there is nothing to race. The second arm is therefore reached only when a palette or a + // colour key beat it -- `best < free_size`, which is also why `chunk_free` is `None` there + // whenever no chunk-free reduction exists at all. + Some(reduced) if best == free_size => Reductions::ChunkFree(reduced), + chunk_free => Reductions::Chunked { + chunked: chunk_carrying(), + chunk_free, + }, } } @@ -351,11 +415,16 @@ pub 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 fn analyze16(samples: &[u16], channels: usize) -> Option { +/// +/// The plain demotion is the floor of the demotable path, not merely its fallback: it adds no +/// chunk, so where the 8-bit analysis offers a chunk-carrying winner and no chunk-free runner-up +/// of its own, the demotion itself becomes that runner-up. Without it a palette that loses the +/// finished file would drop the encoder all the way back to 16 bits, throwing away a halving that +/// costs nothing. +pub fn analyze16(samples: &[u16], channels: usize) -> Reductions { debug_assert!((1..=4).contains(&channels)); if let Some(demoted) = demote16(samples) { - let further = analyze8(&demoted, channels); - return Some(further.unwrap_or(match channels { + let plain = |demoted| match channels { 1 => Reduced::Gray { depth: 8, samples: demoted, @@ -363,7 +432,18 @@ pub fn analyze16(samples: &[u16], channels: usize) -> Option { 2 => Reduced::GrayAlpha8(demoted), 3 => Reduced::Rgb8(demoted), _ => Reduced::Rgba8(demoted), - })); + }; + return match analyze8(&demoted, channels) { + Reductions::None => Reductions::ChunkFree(plain(demoted)), + Reductions::ChunkFree(reduced) => Reductions::ChunkFree(reduced), + Reductions::Chunked { + chunked, + chunk_free, + } => Reductions::Chunked { + chunked, + chunk_free: Some(chunk_free.unwrap_or_else(|| plain(demoted))), + }, + }; } let mut all_opaque = true; @@ -380,22 +460,23 @@ pub fn analyze16(samples: &[u16], channels: usize) -> Option { // Unlike the 8-bit analysis there is no size estimate to weigh: the candidates' gates are // mutually exclusive, and each strictly shrinks the channel count, so whichever gate matches // wins outright. The channel checks reject the identity "reductions" (grey of a Gray16 input, - // grey+alpha of a GrayAlpha16 input). + // grey+alpha of a GrayAlpha16 input). None of them carries a chunk -- PNG has no 16-bit + // palette and this arm found no demotion -- so there is never anything here to race. let px16 = samples.chunks_exact(channels); if all_gray && all_opaque && channels > 1 { - Some(Reduced::Gray16Be(be_bytes(px16.map(|px| px[0])))) + Reductions::ChunkFree(Reduced::Gray16Be(be_bytes(px16.map(|px| px[0])))) } else if all_gray && channels > 2 { // Not all-opaque (that is the branch above), so the alpha channel must be kept. - Some(Reduced::GrayAlpha16Be(be_bytes( + Reductions::ChunkFree(Reduced::GrayAlpha16Be(be_bytes( px16.flat_map(|px| [px[0], px[channels - 1]]), ))) } else if channels == 4 && all_opaque { // Not all-grey (the branches above), so only the opaque alpha channel can be dropped. - Some(Reduced::Rgb16Be(be_bytes( + Reductions::ChunkFree(Reduced::Rgb16Be(be_bytes( px16.flat_map(|px| [px[0], px[1], px[2]]), ))) } else { - None + Reductions::None } } @@ -566,8 +647,10 @@ mod tests { // Opaque, non-grey RGBA -> RGB. let rgba = [10, 20, 30, 255, 40, 50, 60, 255]; match analyze8(&rgba, 4) { - Some(Reduced::Rgb8(rgb)) => assert_eq!(rgb, vec![10, 20, 30, 40, 50, 60]), - _ => panic!("expected Rgb8"), + Reductions::ChunkFree(Reduced::Rgb8(rgb)) => { + assert_eq!(rgb, vec![10, 20, 30, 40, 50, 60]); + } + _ => panic!("expected a chunk-free Rgb8"), } } @@ -576,10 +659,10 @@ mod tests { // Opaque R=G=B RGB with many levels -> 8-bit grey. let rgb: Vec = (0..60u8).flat_map(|v| [v, v, v]).collect(); match analyze8(&rgb, 3) { - Some(Reduced::Gray { depth: 8, samples }) => { + Reductions::ChunkFree(Reduced::Gray { depth: 8, samples }) => { assert_eq!(samples, (0..60u8).collect::>()); } - _ => panic!("expected 8-bit Gray"), + _ => panic!("expected a chunk-free 8-bit Gray"), } } @@ -601,10 +684,10 @@ mod tests { rgba.extend_from_slice(&[g, g, g, a]); } match analyze8(&rgba, 4) { - Some(Reduced::GrayAlpha8(samples)) => { + Reductions::ChunkFree(Reduced::GrayAlpha8(samples)) => { assert_eq!(samples.len(), 600, "two bytes per pixel"); } - _ => panic!("expected GrayAlpha8"), + _ => panic!("expected a chunk-free GrayAlpha8"), } } @@ -629,9 +712,13 @@ mod tests { rgba.extend_from_slice(&[c, c.wrapping_add(64), 200, 255]); } match analyze8(&rgba, 4) { - Some(Reduced::Indexed { - depth, plte, trns, .. - }) => { + Reductions::Chunked { + chunked: + Reduced::Indexed { + depth, plte, trns, .. + }, + .. + } => { assert_eq!(depth, 8, "200 entries need the 8-bit index depth"); assert_eq!(plte.len(), 200 * 3); assert_eq!(trns, None, "an all-opaque palette carries no tRNS"); @@ -640,6 +727,93 @@ mod tests { } } + /// A palette that wins the estimate still hands over the alpha drop it beat. + /// + /// The defect this pins: the estimate collapsed five candidates to one, and only that + /// one was ever encoded. On an opaque RGBA image with few enough colours the palette wins the + /// raw comparison — 350 + 600 + 24 against 1050 — and then loses the *finished* file to + /// `PLTE`'s 600 incompressible bytes, at which point the encoder fell back to the unreduced + /// RGBA and kept an alpha channel that is 255 everywhere. `Reductions::Chunked` carries the + /// runner-up so `write_reduced_or_native` can measure it too. + /// + /// The alpha drop, not the greyscale collapse or the grey+alpha one: the three chunk-free + /// gates are mutually exclusive and this fixture is opaque and not grey, so `free_size` can + /// only be `rgb_size`. + #[test] + fn an_opaque_palette_hands_over_the_alpha_drop_it_beat() { + let mut rgba = Vec::new(); + for i in 0..350u32 { + // 200 distinct colours, none grey (R != G), all fully opaque. + let c = (i % 200) as u8; + rgba.extend_from_slice(&[c, c.wrapping_add(64), 200, 255]); + } + match analyze8(&rgba, 4) { + Reductions::Chunked { + chunked: Reduced::Indexed { .. }, + chunk_free: Some(Reduced::Rgb8(rgb)), + } => { + assert_eq!(rgb.len(), 350 * 3, "three channels, one alpha dropped"); + assert_eq!(&rgb[0..6], &[0, 64, 200, 1, 65, 200]); + } + _ => panic!("expected Indexed with an Rgb8 runner-up"), + } + } + + /// An identity layout is not offered as a runner-up, because encoding it is encoding nothing. + /// + /// This grey+alpha input reduces to a 1-bit palette, so a runner-up would be raced against it. + /// The only chunk-free candidate its gates admit is grey+alpha — which is the input layout + /// spelled again, `pixel_count * 2` against an input of exactly `pixel_count * 2`. Offering it + /// would make the encoder filter and DEFLATE the same samples a second time to arrive at the + /// file the unreduced candidate already produces, so `analyze8` requires a *strict* saving + /// before it names a runner-up. + #[test] + fn an_identity_layout_is_not_offered_as_a_runner_up() { + let ga: Vec = [0, 0, 255, 255].repeat(40); // transparent black, opaque white + match analyze8(&ga, 2) { + Reductions::Chunked { + chunked: Reduced::Indexed { .. }, + chunk_free, + } => assert!( + chunk_free.is_none(), + "grey+alpha of a grey+alpha input stores exactly the input's bytes" + ), + _ => panic!("expected Indexed"), + } + } + + /// A demotable 16-bit image keeps its demotion as the runner-up when a palette wins. + /// + /// The 16-bit half of the same defect. `analyze16` demotes, re-analyses at 8 bits, and + /// used the plain demotion only when the 8-bit analysis found *nothing*. When the 8-bit + /// analysis found a palette instead, the demotion was discarded — and if the palette then lost + /// the finished file, the encoder fell back to the 16-bit input and threw away a halving that + /// costs no chunk. Here 64 opaque non-grey colours give an 8-bit palette and no chunk-free + /// 8-bit candidate (the input is already RGB), so the demotion is the only runner-up there is. + #[test] + fn a_demotable_palette_keeps_the_plain_demotion_as_its_runner_up() { + let rgb16: Vec = (0..600u32) + .flat_map(|i| { + let c = (i % 64) as u8; + [ + u16::from(c) * 257, + u16::from(c.wrapping_add(64)) * 257, + 200 * 257, + ] + }) + .collect(); + match analyze16(&rgb16, 3) { + Reductions::Chunked { + chunked: Reduced::Indexed { .. }, + chunk_free: Some(Reduced::Rgb8(demoted)), + } => { + assert_eq!(demoted.len(), 600 * 3, "half the 16-bit payload"); + assert_eq!(&demoted[0..3], &[0, 64, 200]); + } + _ => panic!("expected Indexed with a demoted Rgb8 runner-up"), + } + } + /// A translucent palette pays for its tRNS table, and the `+ 24` chunk overhead is real. /// /// The companion to `palette_overhead_decides_against_rgb_at_the_margin`, which uses an @@ -662,7 +836,10 @@ mod tests { rgba.extend_from_slice(&[c, c.wrapping_add(64), 200, 128]); } match analyze8(&rgba, 4) { - Some(Reduced::Indexed { depth, trns, .. }) => { + Reductions::Chunked { + chunked: Reduced::Indexed { depth, trns, .. }, + .. + } => { assert_eq!(depth, 8, "200 entries need the 8-bit index depth"); assert_eq!( trns.map(|t| t.len()), @@ -690,7 +867,10 @@ mod tests { rgba.extend_from_slice(&[70, 80, 90, 255]); } match analyze8(&rgba, 4) { - Some(Reduced::Indexed { trns, .. }) => { + Reductions::Chunked { + chunked: Reduced::Indexed { trns, .. }, + .. + } => { assert_eq!( trns, Some(vec![128]), @@ -713,12 +893,16 @@ mod tests { } } match analyze8(&rgb, 3) { - Some(Reduced::Indexed { - depth, - plte, - trns, - indices, - }) => { + Reductions::Chunked { + chunked: + Reduced::Indexed { + depth, + plte, + trns, + indices, + }, + .. + } => { assert_eq!(depth, 1); assert_eq!(plte.len(), 6); // two RGB entries assert!(trns.is_none()); @@ -734,7 +918,7 @@ mod tests { let rgb: Vec = (0..300u32) .flat_map(|i| [i as u8, (i >> 1) as u8, (i >> 2) as u8]) .collect(); - assert!(analyze8(&rgb, 3).is_none()); + assert!(matches!(analyze8(&rgb, 3), Reductions::None)); } /// Rec. 601 luma is the *only* thing separating these five opaque entries -- same alpha, so @@ -793,7 +977,10 @@ mod tests { rgba.extend_from_slice(&[0, 0, 0, 0].repeat(40)); // invisible, discovered last match analyze8(&rgba, 4) { - Some(Reduced::Indexed { plte, trns, .. }) => { + Reductions::Chunked { + chunked: Reduced::Indexed { plte, trns, .. }, + .. + } => { assert_eq!( plte, vec![0, 0, 0, 200, 10, 10, 255, 255, 255], @@ -813,7 +1000,10 @@ mod tests { ] .repeat(20); match analyze8(&rgba, 4) { - Some(Reduced::Indexed { trns: Some(t), .. }) => assert_eq!(t, vec![0]), + Reductions::Chunked { + chunked: Reduced::Indexed { trns: Some(t), .. }, + .. + } => assert_eq!(t, vec![0]), _ => panic!("expected indexed with tRNS"), } } @@ -821,14 +1011,14 @@ mod tests { /// Asserts `pixels` (of `channels`) reduces to grey at `depth` with the expected codes. fn expect_gray(pixels: &[u8], channels: usize, depth: u8, codes: &[u8]) { match analyze8(pixels, channels) { - Some(Reduced::Gray { + Reductions::ChunkFree(Reduced::Gray { depth: got, samples, }) => { assert_eq!(got, depth, "depth"); assert_eq!(samples, codes, "codes"); } - _ => panic!("expected Gray at depth {depth}"), + _ => panic!("expected a chunk-free Gray at depth {depth}"), } } @@ -856,9 +1046,13 @@ mod tests { // 2 bits still beats 8-bit grey. let gray: Vec = [5u8, 9, 200].repeat(40); match analyze8(&gray, 1) { - Some(Reduced::Indexed { - depth, plte, trns, .. - }) => { + Reductions::Chunked { + chunked: + Reduced::Indexed { + depth, plte, trns, .. + }, + .. + } => { assert_eq!(depth, 2); assert_eq!(plte, vec![5, 5, 5, 9, 9, 9, 200, 200, 200]); assert!(trns.is_none()); @@ -872,7 +1066,7 @@ mod tests { // 8-bit grey using values off every sub-byte grid and >16 distinct levels: nothing beats // the input. let gray: Vec = (0..=255u8).collect(); - assert!(analyze8(&gray, 1).is_none()); + assert!(matches!(analyze8(&gray, 1), Reductions::None)); } #[test] @@ -885,12 +1079,16 @@ mod tests { fn grey_alpha_with_few_combinations_is_indexed_with_trns() { let ga: Vec = [0, 0, 255, 255].repeat(40); // transparent black, opaque white match analyze8(&ga, 2) { - Some(Reduced::Indexed { - depth, - plte, - trns: Some(t), + Reductions::Chunked { + chunked: + Reduced::Indexed { + depth, + plte, + trns: Some(t), + .. + }, .. - }) => { + } => { assert_eq!(depth, 1); assert_eq!(plte, vec![0, 0, 0, 255, 255, 255]); assert_eq!(t, vec![0]); @@ -926,7 +1124,10 @@ mod tests { .collect(); match analyze8(&ga, 2) { - Some(Reduced::GrayKeyed { samples, key }) => { + Reductions::Chunked { + chunked: 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. @@ -937,13 +1138,19 @@ mod tests { } } other => panic!( - "expected GrayKeyed, got {}", + "expected a chunk-carrying GrayKeyed, got {}", match other { - Some(Reduced::Indexed { .. }) => "Indexed", - Some(Reduced::GrayAlpha8(_)) => "GrayAlpha8", - Some(Reduced::Rgb8Keyed { .. }) => "Rgb8Keyed", - Some(_) => "some other reduction", - None => "no reduction", + Reductions::Chunked { + chunked: Reduced::Indexed { .. }, + .. + } => "Indexed", + Reductions::Chunked { + chunked: Reduced::Rgb8Keyed { .. }, + .. + } => "Rgb8Keyed", + Reductions::ChunkFree(Reduced::GrayAlpha8(_)) => "GrayAlpha8", + Reductions::ChunkFree(_) | Reductions::Chunked { .. } => "some other reduction", + Reductions::None => "no reduction", } ), } @@ -954,7 +1161,7 @@ mod tests { let ga: Vec = (0..600u32) .flat_map(|i| [(i % 251) as u8, (i % 249) as u8]) .collect(); - assert!(analyze8(&ga, 2).is_none()); + assert!(matches!(analyze8(&ga, 2), Reductions::None)); } #[test] @@ -967,10 +1174,10 @@ mod tests { }) .collect(); match analyze16(&rgba16, 4) { - Some(Reduced::Gray { depth: 8, samples }) => { + Reductions::ChunkFree(Reduced::Gray { depth: 8, samples }) => { assert_eq!(samples, (0..80).map(|i| (i % 60) as u8).collect::>()); } - _ => panic!("expected 8-bit Gray"), + _ => panic!("expected a chunk-free 8-bit Gray"), } } @@ -988,7 +1195,7 @@ mod tests { }) .collect(); match analyze16(&rgba16, 4) { - Some(Reduced::Rgba8(demoted)) => { + Reductions::ChunkFree(Reduced::Rgba8(demoted)) => { assert_eq!(demoted.len(), 600 * 4); assert_eq!(demoted[0..4], [0, 0, 0, 0]); assert_eq!(demoted[4 * 250], 250u8); @@ -1001,7 +1208,7 @@ mod tests { fn two_matching_channels_are_not_grey() { // R == G but B differs on every pixel: not greyscale, too many colours to palette. let rgb: Vec = (0..60u8).flat_map(|v| [v, v, 200]).collect(); - assert!(analyze8(&rgb, 3).is_none()); + assert!(matches!(analyze8(&rgb, 3), Reductions::None)); // The 16-bit twin (non-demotable): same verdict. let rgb16: Vec = (0..60u32) .flat_map(|i| { @@ -1009,14 +1216,14 @@ mod tests { [v, v, 200] }) .collect(); - assert!(analyze16(&rgb16, 3).is_none()); + assert!(matches!(analyze16(&rgb16, 3), Reductions::None)); } #[test] fn sixteen_bit_identity_reductions_are_rejected() { // Non-demotable grey noise arriving as Gray16 is already minimal. let gray16: Vec = (0..90u32).map(|i| (i * 501 + 1) as u16).collect(); - assert!(analyze16(&gray16, 1).is_none()); + assert!(matches!(analyze16(&gray16, 1), Reductions::None)); } #[test] @@ -1027,8 +1234,10 @@ mod tests { .flat_map(|i| [((i % 251) * 257) as u16, ((i % 33) * 7 * 257) as u16]) .collect(); match analyze16(&ga16, 2) { - Some(Reduced::GrayAlpha8(demoted)) => assert_eq!(demoted.len(), 600 * 2), - _ => panic!("expected demoted GrayAlpha8"), + Reductions::ChunkFree(Reduced::GrayAlpha8(demoted)) => { + assert_eq!(demoted.len(), 600 * 2); + } + _ => panic!("expected a chunk-free demoted GrayAlpha8"), } // Rgb16, every sample k*257, many non-grey colours: plain demotion keeps RGB. @@ -1042,8 +1251,10 @@ mod tests { }) .collect(); match analyze16(&rgb16, 3) { - Some(Reduced::Rgb8(demoted)) => assert_eq!(demoted.len(), 600 * 3), - _ => panic!("expected demoted Rgb8"), + Reductions::ChunkFree(Reduced::Rgb8(demoted)) => { + assert_eq!(demoted.len(), 600 * 3); + } + _ => panic!("expected a chunk-free demoted Rgb8"), } } @@ -1060,10 +1271,10 @@ mod tests { rgba16[1] = 0x0100; rgba16[2] = 0x0100; // keep the pixel grey so the native grey reduction still applies match analyze16(&rgba16, 4) { - Some(Reduced::Gray16Be(bytes)) => { + Reductions::ChunkFree(Reduced::Gray16Be(bytes)) => { assert_eq!(&bytes[0..2], &[0x01, 0x00], "big-endian, undemoted"); } - _ => panic!("expected Gray16Be"), + _ => panic!("expected a chunk-free Gray16Be"), } } @@ -1074,11 +1285,11 @@ mod tests { .flat_map(|i| [(i * 501 + 1) as u16, (i * 703 + 2) as u16, 3, u16::MAX]) .collect(); match analyze16(&rgba16, 4) { - Some(Reduced::Rgb16Be(bytes)) => { + Reductions::ChunkFree(Reduced::Rgb16Be(bytes)) => { assert_eq!(bytes.len(), 90 * 6); assert_eq!(&bytes[0..6], &[0, 1, 0, 2, 0, 3]); } - _ => panic!("expected Rgb16Be"), + _ => panic!("expected a chunk-free Rgb16Be"), } // Grey non-demotable RGB16 -> Gray16. @@ -1090,7 +1301,7 @@ mod tests { .collect(); assert!(matches!( analyze16(&rgb16, 3), - Some(Reduced::Gray16Be(bytes)) if bytes.len() == 90 * 2 + Reductions::ChunkFree(Reduced::Gray16Be(bytes)) if bytes.len() == 90 * 2 )); // Opaque non-demotable GrayAlpha16 -> Gray16. @@ -1099,7 +1310,7 @@ mod tests { .collect(); assert!(matches!( analyze16(&ga16, 2), - Some(Reduced::Gray16Be(bytes)) if bytes.len() == 90 * 2 + Reductions::ChunkFree(Reduced::Gray16Be(bytes)) if bytes.len() == 90 * 2 )); // A translucent grey+alpha pair -> gets a full 16-bit gray+alpha only when smaller, which @@ -1107,7 +1318,7 @@ mod tests { let translucent: Vec = (0..90u32) .flat_map(|i| [(i * 501 + 1) as u16, (i * 703) as u16 | 1]) .collect(); - assert!(analyze16(&translucent, 2).is_none()); + assert!(matches!(analyze16(&translucent, 2), Reductions::None)); let noise: Vec = (0..600u32) .flat_map(|i| { [ @@ -1118,7 +1329,7 @@ mod tests { ] }) .collect(); - assert!(analyze16(&noise, 4).is_none()); + assert!(matches!(analyze16(&noise, 4), Reductions::None)); } #[test] @@ -1131,8 +1342,10 @@ mod tests { }) .collect(); match analyze16(&rgba16, 4) { - Some(Reduced::GrayAlpha16Be(bytes)) => assert_eq!(bytes.len(), 90 * 4), - _ => panic!("expected GrayAlpha16Be"), + Reductions::ChunkFree(Reduced::GrayAlpha16Be(bytes)) => { + assert_eq!(bytes.len(), 90 * 4); + } + _ => panic!("expected a chunk-free GrayAlpha16Be"), } } } diff --git a/crates/gamut-png/tests/common/corpus.rs b/crates/gamut-png/tests/common/corpus.rs index 9883db50..a4f3d34c 100644 --- a/crates/gamut-png/tests/common/corpus.rs +++ b/crates/gamut-png/tests/common/corpus.rs @@ -134,6 +134,63 @@ pub fn sprite_rgba(side: u32) -> Vec { buf } +/// The colour index of the 8x8 cell a pixel falls in, over a 16-wide grid: 256 distinct indices +/// at 128x128, 64 at 64x64, and never more than 256 whatever the side. +/// +/// Shared by the two fixtures below so the 16-bit row is the 8-bit row's own colours widened, and +/// the pair differs in exactly the axis it is there to measure. +fn cell_index(x: u32, y: u32) -> u8 { + ((x / 8 + (y / 8) * 16) % 256) as u8 +} + +/// The colour a cell index carries. `i -> 7i` is a bijection on `u8` (7 is odd), so the fixture +/// has exactly as many distinct colours as it has cells, and no colour is ever grey. +fn cell_colour(idx: u8) -> [u8; 3] { + [ + idx.wrapping_mul(7), + idx.wrapping_mul(3).wrapping_add(40), + 255 - idx.wrapping_mul(5), + ] +} + +/// Opaque RGBA8 over at most 256 distinct colours: the row where the *palette* wins the raw +/// estimate and loses the finished file, so the reduction that must actually be emitted is the +/// runner-up the estimate eliminated — the alpha drop. +/// +/// Every other RGBA entry here is either translucent (`palette64_rgba`, `sprite_rgba`) or a single +/// colour (`flat_rgba`), so none of them can reach an alpha drop that is also palettisable. That +/// gap is why the encoder kept a 255-everywhere alpha channel on this shape unnoticed. +pub fn opaque256_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 [r, g, b] = cell_colour(cell_index(x, y)); + buf.extend_from_slice(&[r, g, b, 255]); + } + } + buf +} + +/// [`opaque256_rgba`]'s colours as 16-bit RGB with every sample `k*257`, big-endian as the file +/// stores them: the lossless 16→8 demotion, under a palette that also applies. +/// +/// The 16-bit twin of the same gap. `photo_rgb` is deliberately 16-bit-hostile and no other entry +/// is 16-bit at all, so nothing in the corpus could see the demotion being discarded whenever the +/// 8-bit analysis found a palette — which left the file at depth 16. +pub fn demotable_rgb16(side: u32) -> Vec { + let mut buf = Vec::with_capacity((side * side * 6) as usize); + for y in 0..side { + for x in 0..side { + for sample in cell_colour(cell_index(x, y)) { + // v = k*257 is the exact inverse of the decoder's 8->16 widening, so the demotion + // back to `sample` is lossless. + buf.extend_from_slice(&(u16::from(sample) * 257).to_be_bytes()); + } + } + } + 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 { diff --git a/crates/gamut-png/tests/size_contract.rs b/crates/gamut-png/tests/size_contract.rs index 0bad0a4b..1808e668 100644 --- a/crates/gamut-png/tests/size_contract.rs +++ b/crates/gamut-png/tests/size_contract.rs @@ -14,7 +14,7 @@ mod common; -use gamut_core::{Dimensions, EncodeImage, ImageRef, Rgb8, Rgba8}; +use gamut_core::{Dimensions, EncodeImage, ImageRef, Rgb8, Rgb16, Rgba8}; use gamut_png::{FilterStrategy, Level, PngEncoder, deconstruct}; /// One case's size budget against libpng at zlib level 9. @@ -27,6 +27,8 @@ struct Budget { fixture: &'static str, /// The square side to measure at. side: u32, + /// Bits per sample of the source layout: 8 for every row but the 16-bit one. + depth: u8, /// 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". @@ -49,7 +51,8 @@ struct Budget { /// 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. +/// the coverage and mutation lanes) except `tiny_rgb8`, which is the bench's own 16x16 row, and +/// `demotable_rgb16`, which halves the side again because its samples are twice as wide. /// /// 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 @@ -61,6 +64,7 @@ const BUDGETS: &[Budget] = &[ name: "gradient_rgb8", fixture: "gradient_rgb8", side: 128, + depth: 8, cleanup: false, max_ratio: 0.82, measured: 0.772, @@ -72,6 +76,7 @@ const BUDGETS: &[Budget] = &[ name: "photo_rgb8", fixture: "photo_rgb8", side: 128, + depth: 8, cleanup: false, max_ratio: 0.83, measured: 0.731, @@ -84,6 +89,7 @@ const BUDGETS: &[Budget] = &[ name: "noise_rgb8", fixture: "noise_rgb8", side: 128, + depth: 8, cleanup: false, max_ratio: 1.02, measured: 0.998, @@ -95,6 +101,7 @@ const BUDGETS: &[Budget] = &[ name: "grey_as_rgb8", fixture: "grey_as_rgb8", side: 128, + depth: 8, cleanup: false, max_ratio: 0.62, measured: 0.582, @@ -105,6 +112,7 @@ const BUDGETS: &[Budget] = &[ name: "flat_rgba8", fixture: "flat_rgba8", side: 128, + depth: 8, cleanup: false, max_ratio: 0.36, measured: 0.321, @@ -116,6 +124,7 @@ const BUDGETS: &[Budget] = &[ name: "sprite_rgba8", fixture: "sprite_rgba8", side: 128, + depth: 8, cleanup: false, max_ratio: 0.99, measured: 0.963, @@ -131,6 +140,7 @@ const BUDGETS: &[Budget] = &[ name: "sprite_rgba8 +clean", fixture: "sprite_rgba8", side: 128, + depth: 8, cleanup: true, max_ratio: 0.70, measured: 0.665, @@ -143,6 +153,7 @@ const BUDGETS: &[Budget] = &[ name: "palette64_rgba8", fixture: "palette64_rgba8", side: 128, + depth: 8, cleanup: false, max_ratio: 0.95, measured: 0.899, @@ -157,6 +168,7 @@ const BUDGETS: &[Budget] = &[ name: "palette64_rgba8 +clean", fixture: "palette64_rgba8", side: 128, + depth: 8, cleanup: true, max_ratio: 0.95, measured: 0.899, @@ -169,10 +181,42 @@ const BUDGETS: &[Budget] = &[ `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: "opaque256_rgba8", + fixture: "opaque256_rgba8", + side: 128, + depth: 8, + cleanup: false, + max_ratio: 0.78, + measured: 0.741, + why: "256 opaque colours, so a palette and an alpha drop both apply. The palette wins the \ + raw estimate (16 384 + 792 against 49 152) and loses the finished file to PLTE's \ + 768 incompressible bytes, which is exactly the case no other row had: this one is \ + the gate on `write_reduced_or_native` racing the chunk-free runner-up the estimate \ + eliminated rather than falling back to the unreduced image. It emitted 349 bytes \ + with the alpha channel intact before that race existed, against 317 now.", + }, + Budget { + name: "demotable_rgb16", + fixture: "demotable_rgb16", + side: 64, + depth: 16, + cleanup: false, + max_ratio: 0.68, + measured: 0.644, + why: "the 16-bit twin of the row above, and the corpus's only 16-bit entry. Every sample \ + is `k*257`, so the demotion to 8 bits is lossless and halves the payload before \ + anything else runs -- but a palette also applies to the demoted image and wins the \ + raw estimate, and the demotion used to be discarded with it: 220 bytes at depth 16 \ + before, 172 at depth 8 now. Measured at 64x64, a quarter of the other rows' pixel \ + count, because a 16-bit source carries twice the samples through three candidate \ + encodings and this file runs in the coverage and mutation lanes.", + }, Budget { name: "tiny_rgb8", fixture: "tiny_rgb8", side: 16, + depth: 8, cleanup: false, max_ratio: 0.95, measured: 0.862, @@ -195,6 +239,11 @@ fn pixels(fixture: &str, side: u32) -> (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), + // Opaque RGBA with a palette *and* an alpha drop available: the row that measures which + // of the two the encoder actually emits. + "opaque256_rgba8" => (common::corpus::opaque256_rgba(side), 4), + // The same colours at 16 bits, every sample `k*257`: the demotion under a palette. + "demotable_rgb16" => (common::corpus::demotable_rgb16(side), 3), // 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}"), @@ -206,7 +255,7 @@ fn pixels(fixture: &str, side: u32) -> (Vec, usize) { /// `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 { +fn gamut_best(samples: &[u8], channels: usize, depth: u8, side: u32, cleanup: bool) -> Vec { let encoder = PngEncoder::new() .with_compression(Level::Best) .with_filter(FilterStrategy::BruteForce) @@ -214,7 +263,18 @@ fn gamut_best(samples: &[u8], channels: usize, side: u32, cleanup: bool) -> Vec< .with_transparent_cleanup(cleanup); let dims = Dimensions::new(side, side).expect("valid dimensions"); let mut out = Vec::new(); - if channels == 3 { + if depth == 16 { + // The corpus stores 16-bit rows the way the file does -- big-endian pairs -- so libpng + // takes them as they are and only gamut's `ImageRef` needs the samples widened back. + let wide: Vec = samples + .as_chunks::<2>() + .0 + .iter() + .map(|&p| u16::from_be_bytes(p)) + .collect(); + let image = ImageRef::::new(&wide, dims).expect("buffer matches dimensions"); + encoder.encode_image(image, &mut out).expect("encode"); + } else if channels == 3 { let image = ImageRef::::new(samples, dims).expect("buffer matches dimensions"); encoder.encode_image(image, &mut out).expect("encode"); } else { @@ -226,7 +286,7 @@ fn gamut_best(samples: &[u8], channels: usize, side: u32, cleanup: bool) -> 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, side: u32) -> Vec { +fn libpng9(samples: &[u8], channels: usize, depth: u8, side: u32) -> Vec { let color_type = if channels == 3 { libpng_oracle::COLOR_RGB } else { @@ -237,7 +297,7 @@ fn libpng9(samples: &[u8], channels: usize, side: u32) -> Vec { side, side, color_type, - 8, + depth, &libpng_oracle::EncodeOpts { compression_level: Some(9), ..libpng_oracle::EncodeOpts::default() @@ -249,8 +309,14 @@ fn libpng9(samples: &[u8], channels: usize, side: u32) -> Vec { fn gamut_never_exceeds_its_size_budget_against_libpng9() { for budget in BUDGETS { 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 ours = gamut_best( + &samples, + channels, + budget.depth, + budget.side, + budget.cleanup, + ); + let theirs = libpng9(&samples, channels, budget.depth, 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. @@ -288,11 +354,19 @@ fn gamut_beats_libpng9_where_it_claims_to() { "flat_rgba8", "sprite_rgba8", "palette64_rgba8", + "opaque256_rgba8", + "demotable_rgb16", ]; for budget in BUDGETS.iter().filter(|b| WINS.contains(&b.name)) { 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 ours = gamut_best( + &samples, + channels, + budget.depth, + budget.side, + budget.cleanup, + ); + let theirs = libpng9(&samples, channels, budget.depth, budget.side); assert!( ours.len() < theirs.len(), "{}: claims a structural win but measured {} vs {}", @@ -318,8 +392,8 @@ fn the_codestream_is_no_larger_where_both_encoders_choose_the_same_representatio // 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); - let theirs = libpng9(&samples, channels, SIDE); + let ours = gamut_best(&samples, channels, 8, SIDE, false); + let theirs = libpng9(&samples, channels, 8, SIDE); let (a, b) = ( deconstruct(&ours).expect("gamut output deconstructs"), deconstruct(&theirs).expect("libpng output deconstructs"), @@ -348,8 +422,20 @@ 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.fixture, budget.side); - let first = gamut_best(&samples, channels, budget.side, budget.cleanup); - let second = gamut_best(&samples, channels, budget.side, budget.cleanup); + let first = gamut_best( + &samples, + channels, + budget.depth, + budget.side, + budget.cleanup, + ); + let second = gamut_best( + &samples, + channels, + budget.depth, + budget.side, + budget.cleanup, + ); assert_eq!(first, second, "{}: encode is not reproducible", budget.name); } } @@ -366,8 +452,8 @@ fn cleanup_never_costs_bytes_on_any_corpus_row() { // 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); + let plain = gamut_best(&samples, channels, budget.depth, budget.side, false); + let cleaned = gamut_best(&samples, channels, budget.depth, budget.side, true); assert!( cleaned.len() <= plain.len(), "{}: cleanup cost {} bytes ({} -> {}); the race in `cleaned_or_plain` should have \ From 97cb43862d223fa76a8c1df971e6ec3a35df25dc Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Sun, 6 Sep 2026 13:44:04 -0400 Subject: [PATCH 57/94] fix(png): keep the byte-exact encoding when cleanup ties on size `prefers_plain` gave an exact size tie to the *cleaned* encoding. `with_transparent_cleanup` is the crate's one knob that alters stored samples -- every other reduction here is byte-exact -- and it is opt-in for a size win. Where the race finds no size win there is nothing to trade that exactness for, so the tie now keeps the plain encoding, the candidate that changed no sample. `prefers_native` is untouched: a tie there still keeps the palette, which decodes with less work for the same bytes. --- crates/gamut-png/STATUS.md | 2 +- crates/gamut-png/src/encoder.rs | 23 +++++++++++++++-------- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/crates/gamut-png/STATUS.md b/crates/gamut-png/STATUS.md index a099f2d4..9bfb83cb 100644 --- a/crates/gamut-png/STATUS.md +++ b/crates/gamut-png/STATUS.md @@ -135,7 +135,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 | **partial** — every reduction is implemented (grey, alpha-drop, ≤256 palette, 16→8, sub-byte, and a `tRNS` colour key for grey/truecolour) and 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. What is not done is the **selection**. `reduce::analyze8` still resolves *some* candidates on the raw estimate alone, and a raw estimate cannot see DEFLATE (below). Until the three-candidate race below it resolved all of them, and the eliminated runner-up was often the one that won the finished file: an opaque RGBA image with ≤256 colours kept an alpha channel that was 255 everywhere (349 bytes against 317), and a 16-bit image whose samples are all `k·257` kept all sixteen bits (220 against 172). The estimate now hands the best **chunk-free** candidate over beside the chunk-carrying one and `write_reduced_or_native` measures both, which closes that whole family — the chunk-free gates are mutually exclusive, so at most one such candidate ever exists. The remainder is the *pair* that both carry a chunk: where a palette and a `tRNS` colour key are both lawful, only the raw-smaller one is ever encoded. | | 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. 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. | +| 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. A tie keeps the **plain** encoding: cleaning buys its rewritten samples with a size win, and where there is no win there is nothing to buy them with. | | 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] | diff --git a/crates/gamut-png/src/encoder.rs b/crates/gamut-png/src/encoder.rs index 89b4e5a7..aff9cef1 100644 --- a/crates/gamut-png/src/encoder.rs +++ b/crates/gamut-png/src/encoder.rs @@ -483,7 +483,8 @@ impl PngEncoder { /// 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. + /// A tie keeps the *plain* encoding: cleaning is only worth its rewritten samples for a + /// size win, so where there is none the byte-exact candidate stands. See [`prefers_plain`]. fn cleaned_or_plain( &self, cleaned: impl FnOnce(&mut Vec) -> Result, @@ -841,12 +842,15 @@ 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. +/// **A tie keeps the plain encoding.** Every other reduction in this crate is byte-exact; +/// [`with_transparent_cleanup`](PngEncoder::with_transparent_cleanup) is the one knob that alters +/// stored samples, and it is opt-in *for a size win*. Where there is no size win there is nothing +/// to trade the exactness for, so the candidate that changed no sample is kept. 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 + plain_len <= cleaned_len } /// Whether the chunk-free reduction beats the chunk-carrying one, the first step of @@ -1206,10 +1210,13 @@ mod tests { } #[test] - fn a_tie_between_cleaned_and_plain_keeps_the_cleaned_encoding() { + fn a_tie_between_cleaned_and_plain_keeps_the_plain_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"); + assert!( + prefers_plain(10, 10), + "a tie keeps the plain encoding, which altered no stored sample" + ); } #[test] From e943666a1d8a76d50f075c3d9c4092849e76dc4e Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Sun, 6 Sep 2026 13:52:22 -0400 Subject: [PATCH 58/94] fix(png): charge the tally's probe counter per entry examined The counter was incremented once at the head of `record`, outside the lookup, so it read one per call whatever the lookup did with it: the inline probe test passed identically under the quadratic `stats` scan the index replaced, and the timing assertion that used to catch that is gone. Two doc comments claimed the opposite. Move the charge into a single `lookup` method, made where an entry is actually examined. A hash lookup charges one; the linear scan charges one per comparison, which takes the same test to 2 096 128 probes against the 2 048 it asserts. --- crates/gamut-png/src/deconstruct.rs | 51 ++++++++++++++++++++--------- 1 file changed, 35 insertions(+), 16 deletions(-) diff --git a/crates/gamut-png/src/deconstruct.rs b/crates/gamut-png/src/deconstruct.rs index 3ad0bb7e..01c59257 100644 --- a/crates/gamut-png/src/deconstruct.rs +++ b/crates/gamut-png/src/deconstruct.rs @@ -424,7 +424,8 @@ struct ChunkTally { /// 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). + /// complexity assertable by count rather than by clock. Charged by + /// [`lookup`](Self::lookup), which is where the examining happens. #[cfg(test)] probes: usize, } @@ -442,17 +443,11 @@ impl ChunkTally { /// 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. + /// All of its lookup work goes through [`lookup`](Self::lookup), which is where that work is + /// accounted. fn record(&mut self, chunk_type: [u8; 4], payload_len: usize) { - #[cfg(test)] - { - self.probes += 1; - } - match self.index.get(&chunk_type) { - Some(&at) => { + match self.lookup(chunk_type) { + Some(at) => { self.stats[at].count += 1; self.stats[at].payload_bytes += payload_len; } @@ -467,6 +462,28 @@ impl ChunkTally { } } + /// Where `chunk_type`'s entry sits in `stats`, if it has one — the tally's **only** lookup, + /// and the only place the probe counter is charged. + /// + /// The charge is one per entry the strategy *examines*, made where the examining happens: a + /// hash lookup examines the single entry its bucket holds, whatever `stats` already contains, + /// so it charges one and N chunks cost N probes. The linear scan this replaced compares + /// entries in a loop, so the same rule charges one per comparison from inside that loop, and + /// N chunks of N distinct types cost about N²/2 — which is what makes + /// `the_tally_probes_once_per_chunk_whatever_the_number_of_distinct_types` fail if the + /// quadratic walk ever comes back, instead of bounding the walk by the clock. A replacement + /// strategy must keep that rule; charging once per call regardless of the work done would + /// leave the test asserting nothing. + fn lookup(&mut self, chunk_type: [u8; 4]) -> Option { + let at = self.index.get(&chunk_type).copied(); + #[cfg(test)] + { + // One bucket entry examined, whatever `stats` holds. + self.probes += 1; + } + at + } + /// The accumulated totals, in first-appearance order. fn into_stats(self) -> Vec { self.stats @@ -1059,11 +1076,13 @@ 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/`. + /// probes however many distinct types they use. [`ChunkTally::lookup`] charges one probe per + /// entry it examines, so a linear scan over `stats` — the defect the index replaced, + /// quadratic in the number of distinct types — charges one per comparison and costs + /// 2 096 128 probes here (measured, N²/2 to the entry), while the hash lookup charges 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; From e8441df641ff7cbeaa6bed16d5640c851fd2bfe0 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Sun, 6 Sep 2026 13:53:06 -0400 Subject: [PATCH 59/94] test(png): shrink the distinct-type fixture to the count it needs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 262 144 chunks built two ~3.1 MB PNGs to assert a per-entry content claim that holds at any count past a handful. The complexity half of the claim belongs to the inline probe count, as this test's own doc concedes, and the fixture never failed under the quadratic walk anyway — it took about 17 s and completed. 1024 clears `synthetic_type`'s 26 and 676 rollovers, so three of the four type bytes still vary, and costs ~12 KB per half. Renamed: the size was the only thing "at scale" named. --- crates/gamut-png/tests/accounting.rs | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/crates/gamut-png/tests/accounting.rs b/crates/gamut-png/tests/accounting.rs index b072c70c..6226500e 100644 --- a/crates/gamut-png/tests/accounting.rs +++ b/crates/gamut-png/tests/accounting.rs @@ -183,8 +183,8 @@ fn synthetic_type(i: usize) -> [u8; 4] { ] } -/// 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 file whose every chunk type is distinct is tallied one entry per type, in first-appearance +/// order, against the same bytes carrying one type throughout. /// /// 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. @@ -195,13 +195,20 @@ fn synthetic_type(i: usize) -> [u8; 4] { /// 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. 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. +/// the *content* — one `ChunkStats` per distinct type, in order, and one entry counting every +/// chunk when the type repeats — which is what the index exists to produce. +/// +/// **Why 1024**, where this once built 262 144. The content claim is per entry and holds at any +/// count past a handful; what the count must clear is `synthetic_type`'s own arithmetic, whose +/// digits roll over at 26 and 676, so 1024 varies three of the four type bytes and still carries +/// two orders of magnitude more distinct types than any real PNG. The larger figure pinned +/// nothing further — it did not fail under the quadratic defect either, it merely took about +/// 17 s — while costing ~3.1 MB of fixture per half on every `mise run test`, every coverage run, +/// and once per mutant in every `gamut-png` mutation shard. #[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. - const CHUNKS: usize = 262_144; +fn every_distinct_chunk_type_gets_its_own_tally_entry() { + /// Empty chunks between IHDR and IEND: 12 bytes each, so ~12 KB per half. + const CHUNKS: usize = 1024; let build = |distinct: bool| { let mut framed = Vec::with_capacity(CHUNKS + 2); From cda73e3f380dbedf22bdd9be0b17e0b9b2f8c004 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Sun, 6 Sep 2026 13:54:36 -0400 Subject: [PATCH 60/94] feat(png): name the inflation-ratio refusal apart from the byte budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both the image exceeding the caller's budget and the ratio guard refusing a suspected zlib bomb reported `OverBudget`, so `gamut inspect` told a valid flat 16384x16384 RGBA8 PNG — exactly the gigabyte it budgets, not one byte over — that it was larger than the reader's byte budget, and exited 1 blaming a limit the file meets. Append `ImplausibleInflation = 4` (the discriminants are permanent and append-only; the enum is non_exhaustive) and give the CLI the accurate message. Neither refusal is damage, so `is_damage` keeps its meaning with both excluded. --- crates/gamut-cli/src/commands/inspect.rs | 7 ++- crates/gamut-png/src/deconstruct.rs | 68 ++++++++++++++++-------- 2 files changed, 53 insertions(+), 22 deletions(-) diff --git a/crates/gamut-cli/src/commands/inspect.rs b/crates/gamut-cli/src/commands/inspect.rs index ebd776e8..73e72a09 100644 --- a/crates/gamut-cli/src/commands/inspect.rs +++ b/crates/gamut-cli/src/commands/inspect.rs @@ -32,7 +32,9 @@ //! 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. +//! stream of zeros is reported as not verified — for the stream's implausible inflation, which is +//! a distinct reason from the image being over budget, since that image is exactly the gigabyte +//! this command admits — and 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 @@ -589,6 +591,9 @@ 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::ImplausibleInflation => { + "the IDAT stream is too short to plausibly inflate to the image the header declares" + } 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", diff --git a/crates/gamut-png/src/deconstruct.rs b/crates/gamut-png/src/deconstruct.rs index 01c59257..3ea0a11d 100644 --- a/crates/gamut-png/src/deconstruct.rs +++ b/crates/gamut-png/src/deconstruct.rs @@ -185,7 +185,7 @@ 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 + /// either damage or a refusal to read, 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] @@ -215,11 +215,9 @@ 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 — 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. + /// 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, read by raising + /// [`DeconstructLimits::max_image_bytes`]. OverBudget = 0, /// The IDAT stream is not a valid zlib stream, is truncated, or inflates past the length the /// header implies. @@ -229,18 +227,30 @@ pub enum SkippedFilterScan { LengthMismatch = 2, /// A scanline's leading byte is not one of the five filter codes §9.1 defines. UndefinedFilterCode = 3, + /// The image fits this reader's byte budget but is past the decoder's default one, and the + /// IDAT 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. + /// + /// Distinct from [`OverBudget`](Self::OverBudget), which the image *exceeding* the budget + /// raises: a file refused here is one whose declared image the budget admits, so saying it is + /// too large would name a limit it does not cross. **Nothing is known to be wrong with the + /// file** either — a flat 16384×16384 image really does compress this far — only that this + /// reader will not spend the budget to find out. + ImplausibleInflation = 4, } 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 + /// variant list. The two reasons that are not damage are the two refusals to read — + /// [`OverBudget`](Self::OverBudget) and + /// [`ImplausibleInflation`](Self::ImplausibleInflation) — which describe what this reader was + /// willing to spend, not what the file contains. 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) + !matches!(self, Self::OverBudget | Self::ImplausibleInflation) } } @@ -536,7 +546,9 @@ pub struct DeconstructLimits { /// 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`]. + /// spent by a zlib bomb. That refusal reports + /// [`SkippedFilterScan::ImplausibleInflation`] — the image fits this budget, so it is not + /// [`OverBudget`](SkippedFilterScan::OverBudget). pub max_image_bytes: usize, /// The largest number of chunks the walk will materialize into segments and per-type stats. /// @@ -778,8 +790,10 @@ fn fits_decode_budget(header: &ihdr::Ihdr, max_image_bytes: usize) -> bool { /// /// 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. +/// image is the one real file this declines, and it is declined as this reader's unwillingness to +/// spend ([`SkippedFilterScan::ImplausibleInflation`]), not as damage — and not as +/// [`OverBudget`](SkippedFilterScan::OverBudget) either, since its image fits the budget that +/// admitted it. /// /// 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 @@ -829,7 +843,7 @@ fn scan_filters( if !fits_decode_budget(header, DEFAULT_MAX_IMAGE_BYTES) && !fits_inflation_ratio(filtered_len, idat.len()) { - return FilterScan::Skipped(SkippedFilterScan::OverBudget); + return FilterScan::Skipped(SkippedFilterScan::ImplausibleInflation); } let Ok(stream) = inflate::inflate_zlib(idat, filtered_len) else { return FilterScan::Skipped(SkippedFilterScan::CorruptStream); @@ -951,10 +965,17 @@ mod tests { } #[test] - fn only_an_over_budget_scan_is_not_damage() { + fn only_a_refusal_to_read_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()); + // stream is a statement about what this reader will spend, everything else about the + // file. Both refusals decline, for different reasons, and neither is damage. + for reason in [ + SkippedFilterScan::OverBudget, + SkippedFilterScan::ImplausibleInflation, + ] { + assert!(!reason.is_damage(), "{reason:?}"); + assert!(!FilterScan::Skipped(reason).is_damage(), "{reason:?}"); + } for reason in [ SkippedFilterScan::CorruptStream, SkippedFilterScan::LengthMismatch, @@ -963,7 +984,6 @@ mod tests { 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], }); @@ -994,6 +1014,7 @@ mod tests { assert_eq!(SkippedFilterScan::CorruptStream as u8, 1); assert_eq!(SkippedFilterScan::LengthMismatch as u8, 2); assert_eq!(SkippedFilterScan::UndefinedFilterCode as u8, 3); + assert_eq!(SkippedFilterScan::ImplausibleInflation as u8, 4); } #[test] @@ -1021,15 +1042,20 @@ mod tests { // 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. + // `ImplausibleInflation` 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.native_bytes(), + Some(1 << 30), + "precondition: the declared image is exactly the budget, so it is not over it" + ); 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" + FilterScan::Skipped(SkippedFilterScan::ImplausibleInflation), + "a stream that would inflate to a gigabyte from four kilobytes is refused for its \ + ratio, not for exceeding a budget it exactly meets" ); assert_eq!( report.filtered_len, From 8a0fc3c2e3a65c91f61940ca5488ad2dccc9c72b Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Sun, 6 Sep 2026 13:55:35 -0400 Subject: [PATCH 61/94] fix(png): count IHDR against the walk's chunk ceiling IHDR is pushed before the loop and the ceiling was only checked inside it, so `with_max_chunks(N)` admitted N + 1 chunks in that one respect and `with_max_chunks(0)` admitted a whole one-chunk file. Check the ceiling where every chunk enters the report instead, so the count means what its name says at every N. The hard error stays: a file at the default ceiling is 12 MiB of pure framing. --- crates/gamut-png/src/deconstruct.rs | 35 ++++++++++++++++++---------- crates/gamut-png/tests/accounting.rs | 14 +++++++++++ 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/crates/gamut-png/src/deconstruct.rs b/crates/gamut-png/src/deconstruct.rs index 3ea0a11d..c6c068ba 100644 --- a/crates/gamut-png/src/deconstruct.rs +++ b/crates/gamut-png/src/deconstruct.rs @@ -557,6 +557,10 @@ pub struct DeconstructLimits { /// 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. /// + /// Counted over chunks, **IHDR included**: `with_max_chunks(N)` admits a file of exactly N + /// chunks and refuses one of N + 1, and `with_max_chunks(0)` admits no file at all, since + /// every datastream this walk reports on opens with IHDR. + /// /// 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, @@ -631,7 +635,13 @@ pub fn deconstruct_with_limits(png: &[u8], limits: DeconstructLimits) -> Result< let mut tally = ChunkTally::new(); let mut idat = Vec::new(); let mut saw_iend = false; - let push = |segments: &mut Vec, tally: &mut ChunkTally, chunk: &RawChunk| { + // Every chunk enters the report here, IHDR included, so the ceiling is checked here too: a + // check placed only inside the loop below would let the chunk pushed before it through, and + // `with_max_chunks(N)` would admit N + 1. + let push = |segments: &mut Vec, + tally: &mut ChunkTally, + chunk: &RawChunk| + -> Result<()> { segments.push(Segment { range: chunk.range.clone(), kind: SegmentKind::Chunk { @@ -641,8 +651,18 @@ pub fn deconstruct_with_limits(png: &[u8], limits: DeconstructLimits) -> Result< }, }); tally.record(chunk.chunk_type, chunk.data.len()); + // The signature segment is not a chunk, so the ceiling is over one fewer than the + // segments materialized so far. Nothing but a chunk has been pushed at this point: + // `Truncated` and `Trailer` end the walk. + if segments.len() - 1 > limits.max_chunks { + return Err(Error::invalid_input( + env!("CARGO_PKG_NAME"), + "PNG: more chunks than the walk's ceiling admits", + )); + } + Ok(()) }; - push(&mut segments, &mut tally, &first); + push(&mut segments, &mut tally, &first)?; loop { match reader.next_chunk() { @@ -652,16 +672,7 @@ pub fn deconstruct_with_limits(png: &[u8], limits: DeconstructLimits) -> Result< idat.extend_from_slice(chunk.data); } let is_iend = &chunk.chunk_type == b"IEND"; - push(&mut segments, &mut tally, &chunk); - // 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", - )); - } + push(&mut segments, &mut tally, &chunk)?; if is_iend { saw_iend = true; break; diff --git a/crates/gamut-png/tests/accounting.rs b/crates/gamut-png/tests/accounting.rs index 6226500e..a3a703bf 100644 --- a/crates/gamut-png/tests/accounting.rs +++ b/crates/gamut-png/tests/accounting.rs @@ -581,6 +581,20 @@ fn the_chunk_ceiling_admits_exactly_its_own_count_and_refuses_one_more() { err.to_string().contains("more chunks"), "the error names the ceiling it hit, got: {err}" ); + + // IHDR is a chunk and is counted like one. The walk pushes it before the loop that reads the + // rest, so a ceiling checked only inside that loop let it through and `with_max_chunks(N)` + // meant N + 1 in that one respect — visibly so at zero, which admitted a whole file. + let ihdr_only = common::png_from_chunks(&chunks[..1]); + let report = deconstruct_with_limits(&ihdr_only, DeconstructLimits::default().with_max_chunks(1)) + .expect("one chunk under a ceiling of one"); + assert_eq!(report.chunks.len(), 1, "IHDR alone, and it is a chunk"); + let err = deconstruct_with_limits(&ihdr_only, DeconstructLimits::default().with_max_chunks(0)) + .expect_err("no chunk at all fits a ceiling of zero"); + assert!( + err.to_string().contains("more chunks"), + "the error names the ceiling it hit, got: {err}" + ); } #[test] From 57fa9239a115253766d55e47245a3bc90be4d865 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Sun, 6 Sep 2026 13:57:22 -0400 Subject: [PATCH 62/94] fix(cli): restore the truncated-list notice for TIFF and DNG MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `print_lines` delegates as `print_lines_of(label, lines, lines.len())`, and the notice fired on `total > lines.len()` — always false for that caller. A TIFF or DNG with fifty unknown tags printed the header, twenty lines, and no sign the list had been cut; the PNG caller passes a pre-truncated list with a separate true total, so it was unaffected and nothing saw the regression. Count the hidden entries from what is printed instead, which is right for both callers, and pin that in `hidden_entries`. --- crates/gamut-cli/src/commands/inspect.rs | 40 ++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/crates/gamut-cli/src/commands/inspect.rs b/crates/gamut-cli/src/commands/inspect.rs index 73e72a09..c4ce1b0c 100644 --- a/crates/gamut-cli/src/commands/inspect.rs +++ b/crates/gamut-cli/src/commands/inspect.rs @@ -390,7 +390,8 @@ fn print_lines(label: &str, lines: &[String]) { print_lines_of(label, lines, lines.len()); } -/// [`print_lines`], where `lines` is already truncated and `total` is how many there really are. +/// [`print_lines`], where `lines` may already be 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. @@ -402,11 +403,25 @@ fn print_lines_of(label: &str, lines: &[String], total: usize) { for line in lines.iter().take(MAX_LIST) { println!(" - {line}"); } - if total > lines.len() { - println!(" … and {} more", total - lines.len()); + let hidden = hidden_entries(total, lines.len()); + if hidden > 0 { + println!(" … and {hidden} more"); } } +/// How many of a `total`-entry list this print left unshown, given the `lines` it was handed. +/// +/// Counted from what is actually **printed** — at most [`MAX_LIST`] of them — because the two +/// callers hide entries in different places. [`print_lines`] passes the whole list and its own +/// length, so the `MAX_LIST` cut in the loop is the only thing that hides anything; the PNG +/// caller passes a list already cut to `MAX_LIST` beside the true total, so what it hides are the +/// lines it never built. A notice derived from `total > lines.len()` alone sees only the second +/// and is dead for the first — which is how a TIFF with fifty unknown tags came to print twenty +/// of them and no indication that thirty were missing. +fn hidden_entries(total: usize, lines: usize) -> usize { + total.saturating_sub(lines.min(MAX_LIST)) +} + /// 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> { @@ -616,3 +631,22 @@ fn format_name(format: Format) -> &'static str { fn yes_no(value: bool) -> &'static str { if value { "yes" } else { "no" } } + +#[cfg(test)] +mod tests { + use super::{MAX_LIST, hidden_entries}; + + #[test] + fn the_truncation_notice_counts_the_entries_neither_caller_printed() { + // `print_lines` hands over the whole list, so only the `MAX_LIST` cut hides anything: a + // notice conditioned on `total > lines.len()` can never fire for it, and fifty unknown + // TIFF tags printed twenty lines and nothing else. + assert_eq!(hidden_entries(50, 50), 50 - MAX_LIST); + // The PNG caller hands over a list already cut to `MAX_LIST` with the true total beside + // it; the entries it never built are the hidden ones. + assert_eq!(hidden_entries(50, MAX_LIST), 50 - MAX_LIST); + // A list that fits hides nothing, from either caller. + assert_eq!(hidden_entries(MAX_LIST, MAX_LIST), 0); + assert_eq!(hidden_entries(3, 3), 0); + } +} From 0595ee801464d042cd4c554141c79eeaa4b17b86 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Sun, 6 Sep 2026 13:59:27 -0400 Subject: [PATCH 63/94] docs(cli): record the gamut inspect exit-code contract in docs/ The contract lived only in `inspect.rs`'s module doc, where a caller scripting the command cannot find it. State it in `docs/`, indexed and normative for one thing: the two exit codes, the gate each format is judged by, PNG's third outcome and why only PNG has one, the gibibyte walk budget against the decoder's 64 MiB, and every filter-scan skip reason with whether it is damage. The module doc keeps the reasoning and points at it. --- crates/gamut-cli/src/commands/inspect.rs | 4 + docs/README.md | 1 + docs/inspect-exit-codes.md | 118 +++++++++++++++++++++++ 3 files changed, 123 insertions(+) create mode 100644 docs/inspect-exit-codes.md diff --git a/crates/gamut-cli/src/commands/inspect.rs b/crates/gamut-cli/src/commands/inspect.rs index c4ce1b0c..e66d3958 100644 --- a/crates/gamut-cli/src/commands/inspect.rs +++ b/crates/gamut-cli/src/commands/inspect.rs @@ -5,6 +5,10 @@ //! Prints a report to stdout and exits non-zero when the file is not fully accounted for — //! usable as an archival CI gate. //! +//! The contract itself — the gate per format, the two exit codes, the budgets, and every reason +//! the PNG filter scan declines — is recorded in `docs/inspect-exit-codes.md`, which is normative +//! for it. What follows is why the code is shaped that way. +//! //! # 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 diff --git a/docs/README.md b/docs/README.md index 9a261f87..16f25c7b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,6 +10,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. | +| [`inspect-exit-codes.md`](inspect-exit-codes.md) | What `gamut inspect` exits with and why: the gate each format is judged by, PNG's third outcome (intact but unread), the byte budgets the walk observes, and every reason its filter scan declines. What each walk *finds* is the format crate's contract. | | [`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/inspect-exit-codes.md b/docs/inspect-exit-codes.md new file mode 100644 index 00000000..8f5bba8a --- /dev/null +++ b/docs/inspect-exit-codes.md @@ -0,0 +1,118 @@ +# `gamut inspect` — the exit-code contract + +Normative for **what `gamut inspect` exits with, per format**: the gate each format is judged by, +PNG's third outcome, the budgets the walk observes, and every reason the PNG filter scan can +decline to read. What each walk *finds* is the format crate's own contract; this document is only +about the verdict the command turns those findings into. + +Source: `crates/gamut-cli/src/commands/inspect.rs`, `crates/gamut-cli/src/main.rs`, +`crates/gamut-png/src/deconstruct.rs`, `crates/gamut-tiff/src/deconstruct.rs`, +`crates/gamut-dng/src/deconstruct.rs`. + +## There are two exit codes + +`main` maps a command's `Ok(())` to `ExitCode::SUCCESS` and **every** `Err` to +`ExitCode::FAILURE`, printing `error: {e}` to stderr. So `gamut inspect` exits `0` or `1`, and +nothing else; the distinctions below are carried by the stderr message, not by the code. + +| Exit | Meaning | +| --- | --- | +| `0` | The file passed its format's gate. The report is on stdout. | +| `1` | Either the file failed its gate (a report is still printed to stdout first, and the summary goes to stderr), or the walk could not run at all — the file was unreadable, or the container could not be opened. | + +A gate failure and an unreadable file are **not** distinguished by exit code. A caller that needs +to tell them apart reads the message: a gate failure is `: not fully accounted — …`, +`: not verified — …` or `: not a complete, undamaged PNG datastream — …`; anything +else is the walk itself failing. + +## The gate, per format + +The format is sniffed unless `--format` forces it: a PNG signature, else a readable TIFF whose +IFD 0 carries `DNGVersion` (50706) is a DNG, else TIFF. + +- **TIFF / DNG** — `DeconstructReport::is_fully_accounted()`: every byte classified into exactly + one typed segment, **and** no unknown field type, no unknown tag, no anomaly. Identical in both + crates. +- **PNG** — `PngReport::is_verified()`: `is_intact()` **and** `FilterScan::is_counted()`, i.e. + 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 printed but is **not** the gate: it is true by construction for +every file `deconstruct` accepts (a truncated tail and a trailer each get a segment of their own), +so gating on it would exit `0` on a truncated PNG. It exists so that a walk *bug* makes the +predicate false. + +## PNG alone has a third outcome + +A TIFF or DNG walk reads directories and tags and never touches pixel data, so there is no step in +it the reader can decline: `is_fully_accounted()` never depends on a budget. A PNG's verification +step **is** an inflation of the IDAT stream, and an inflation can be declined. So PNG has three +outcomes where the other two formats have two: + +| PNG state | Exit | stderr | +| --- | --- | --- | +| `is_verified()` | `0` | — | +| `is_intact()` but not verified — nothing is known against the file, but its IDAT was never read | `1` | `: not verified — ` | +| not `is_intact()` — something is known against the file | `1` | `: not a complete, undamaged PNG datastream — N finding(s)` | + +The middle row is why `is_intact()` is not the gate. A file whose filter scan was skipped for +budget is not *damaged* — `intact: yes` is printed truthfully — but a corrupt zlib payload under a +valid CRC is damage **only** the scan can see, so exiting `0` on an unread file would report this +reader's budget as a property of the file. Gating PNG on `is_intact()` instead would leave 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. + +## The budgets the walk observes + +`gamut inspect` walks with `DeconstructLimits::default().with_max_image_bytes(1 << 30)` — one +gibibyte — against the PNG decoder's default of `64 << 20`, 64 MiB (a 4096×4096 RGBA8 image). +`max_chunks` is left at its default, `DEFAULT_MAX_CHUNKS = 1 << 20`. + +They differ because they answer different questions: + +- The **decoder's** 64 MiB bounds what a decode of hostile input may allocate. A file past it is + refused; refusing is the safe outcome, because nothing downstream needs the pixels. +- **Inspection's** whole job is to read the file, and a file it declines to inflate is a file it + cannot vouch for. At 64 MiB every PNG past 4096×4096 RGBA8 — an ordinary photograph — would be + reported as intact but not verified. A gibibyte is past any real image and short of unbounded. + +The gibibyte bounds the **image the header declares**, not what a small file may inflate to. +Past the decoder's default budget the walk additionally refuses, before inflating, any stream that +would grow to more than sixty-four times its own length (`INFLATION_RATIO`), so a raised image +budget cannot be spent by a zlib bomb: a megabyte declaring a 16384×16384 header over a zlib +stream of zeros is refused unread. Inside the decoder's default budget the ratio does not apply — +a flat image really does compress thousands-fold, and the walk is never a cheaper target than a +decode of the same header, which allocates the same bytes. + +What a hostile file can still cost, therefore: an inflation of up to twice the decoder's default +budget (native bytes plus one filter byte per scanline — up to 128 MiB for a degenerate +one-pixel-wide greyscale column) for free, and anything above that only by paying one input byte +for every sixty-four bytes inflated. + +Exceeding `max_chunks` is **not** a finding: the walk returns an error and the command exits `1` +with that error, having printed no report. A PNG at the ceiling carries at least 12 MiB of pure +chunk framing. + +## Why the PNG filter scan declines, and which reasons are damage + +`FilterScan::Skipped(SkippedFilterScan)` names the reason. `SkippedFilterScan::is_damage()` is the +single source of truth for whether the reason describes the **file** or this **reader**; the +command raises a finding for the first kind and reports "not verified" for the second. + +| Reason | Damage? | What it means | PNG outcome | +| --- | --- | --- | --- | +| `OverBudget` | no | The image the header declares is larger than `max_image_bytes`. | intact, not verified → exit `1` | +| `ImplausibleInflation` | no | The image fits this reader's budget but is past the decoder's default one, and the IDAT stream is more than sixty-four times too short to plausibly inflate to it — the shape of a zlib bomb under a permissive budget. | intact, not verified → exit `1` | +| `CorruptStream` | yes | The IDAT stream is not a valid zlib stream, is truncated, or inflates past the length the header implies. | finding → exit `1` | +| `LengthMismatch` | yes | The stream inflated, but not to the length the header implies, so the scanline boundaries are not where the filter bytes are. | finding → exit `1` | +| `UndefinedFilterCode` | yes | A scanline's leading byte is not one of the five filter codes §9.1 defines. | finding → exit `1` | + +The two non-damage reasons are the two ways this reader can refuse to *read*, and they are +distinct because they blame different things: `OverBudget` is the image exceeding a limit, +`ImplausibleInflation` is a file whose declared image the limit admits — a valid flat 16384×16384 +RGBA8 PNG is exactly the gibibyte `gamut inspect` allows, so telling it that it is larger than the +budget would name a limit it does not cross. + +`SkippedFilterScan` is `#[non_exhaustive]` and its `#[repr(u8)]` discriminants are permanent and +append-only. A future reason is **damage until it says otherwise**, so it raises a finding and the +command renders it generically rather than passing a file it does not understand. From c59df6f78a56aaccf778cd24cbd4499bf00c417c Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Sun, 6 Sep 2026 14:03:29 -0400 Subject: [PATCH 64/94] style(png): rustfmt the chunk-ceiling closure and its boundary test Nightly rustfmt's own layout for the two statements the previous commit introduced. No behaviour change. --- crates/gamut-png/src/deconstruct.rs | 46 +++++++++++++--------------- crates/gamut-png/tests/accounting.rs | 5 +-- 2 files changed, 25 insertions(+), 26 deletions(-) diff --git a/crates/gamut-png/src/deconstruct.rs b/crates/gamut-png/src/deconstruct.rs index c6c068ba..88dfcc12 100644 --- a/crates/gamut-png/src/deconstruct.rs +++ b/crates/gamut-png/src/deconstruct.rs @@ -638,30 +638,28 @@ pub fn deconstruct_with_limits(png: &[u8], limits: DeconstructLimits) -> Result< // Every chunk enters the report here, IHDR included, so the ceiling is checked here too: a // check placed only inside the loop below would let the chunk pushed before it through, and // `with_max_chunks(N)` would admit N + 1. - let push = |segments: &mut Vec, - tally: &mut ChunkTally, - chunk: &RawChunk| - -> Result<()> { - 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, - }, - }); - tally.record(chunk.chunk_type, chunk.data.len()); - // The signature segment is not a chunk, so the ceiling is over one fewer than the - // segments materialized so far. Nothing but a chunk has been pushed at this point: - // `Truncated` and `Trailer` end the walk. - if segments.len() - 1 > limits.max_chunks { - return Err(Error::invalid_input( - env!("CARGO_PKG_NAME"), - "PNG: more chunks than the walk's ceiling admits", - )); - } - Ok(()) - }; + let push = + |segments: &mut Vec, tally: &mut ChunkTally, chunk: &RawChunk| -> Result<()> { + 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, + }, + }); + tally.record(chunk.chunk_type, chunk.data.len()); + // The signature segment is not a chunk, so the ceiling is over one fewer than the + // segments materialized so far. Nothing but a chunk has been pushed at this point: + // `Truncated` and `Trailer` end the walk. + if segments.len() - 1 > limits.max_chunks { + return Err(Error::invalid_input( + env!("CARGO_PKG_NAME"), + "PNG: more chunks than the walk's ceiling admits", + )); + } + Ok(()) + }; push(&mut segments, &mut tally, &first)?; loop { diff --git a/crates/gamut-png/tests/accounting.rs b/crates/gamut-png/tests/accounting.rs index a3a703bf..ee7ec629 100644 --- a/crates/gamut-png/tests/accounting.rs +++ b/crates/gamut-png/tests/accounting.rs @@ -586,8 +586,9 @@ fn the_chunk_ceiling_admits_exactly_its_own_count_and_refuses_one_more() { // rest, so a ceiling checked only inside that loop let it through and `with_max_chunks(N)` // meant N + 1 in that one respect — visibly so at zero, which admitted a whole file. let ihdr_only = common::png_from_chunks(&chunks[..1]); - let report = deconstruct_with_limits(&ihdr_only, DeconstructLimits::default().with_max_chunks(1)) - .expect("one chunk under a ceiling of one"); + let report = + deconstruct_with_limits(&ihdr_only, DeconstructLimits::default().with_max_chunks(1)) + .expect("one chunk under a ceiling of one"); assert_eq!(report.chunks.len(), 1, "IHDR alone, and it is a chunk"); let err = deconstruct_with_limits(&ihdr_only, DeconstructLimits::default().with_max_chunks(0)) .expect_err("no chunk at all fits a ceiling of zero"); From 8373af0f3a58bc34f713f5aa9e9f1d3ffe78c275 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 9 Sep 2026 13:47:29 -0400 Subject: [PATCH 65/94] docs(png): say what moving the chunk ceiling into push actually fixed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both comments claimed that a ceiling checked only inside the loop "would let the chunk pushed before it through", so `with_max_chunks(N)` would admit N + 1. That is not what changed. The in-loop check counted `segments.len() - 1`, i.e. every chunk pushed so far including the IHDR pushed before the loop, so for any datastream that reaches the loop body the ceiling already admitted exactly N and refused N + 1 — which is why the ten-chunk boundary assertions pass unmodified on both sides of the move. What escaped was a datastream whose walk pushes no chunk after IHDR, because it is truncated at it or ends there: the in-loop check never ran at all, so such a file passed however small the ceiling was. That is the case the `max_chunks(0)` assertion pins, and both comments now say so. `DeconstructLimits::max_chunks`' own documentation was accurate and is unchanged. --- crates/gamut-png/src/deconstruct.rs | 9 ++++++--- crates/gamut-png/tests/accounting.rs | 6 ++++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/crates/gamut-png/src/deconstruct.rs b/crates/gamut-png/src/deconstruct.rs index 88dfcc12..1b970d5d 100644 --- a/crates/gamut-png/src/deconstruct.rs +++ b/crates/gamut-png/src/deconstruct.rs @@ -635,9 +635,12 @@ pub fn deconstruct_with_limits(png: &[u8], limits: DeconstructLimits) -> Result< let mut tally = ChunkTally::new(); let mut idat = Vec::new(); let mut saw_iend = false; - // Every chunk enters the report here, IHDR included, so the ceiling is checked here too: a - // check placed only inside the loop below would let the chunk pushed before it through, and - // `with_max_chunks(N)` would admit N + 1. + // Every chunk enters the report here, IHDR included, so the ceiling is checked here too. + // A check placed only inside the loop below counts IHDR (it counts every chunk pushed so + // far), so it is exact for any datastream that reaches the loop body — but it never runs for + // one that does not: a walk that pushes no chunk after IHDR, because the datastream is + // truncated at it or ends there, escaped the ceiling entirely. Visible at `max_chunks(0)`, + // which admitted such a file. let push = |segments: &mut Vec, tally: &mut ChunkTally, chunk: &RawChunk| -> Result<()> { segments.push(Segment { diff --git a/crates/gamut-png/tests/accounting.rs b/crates/gamut-png/tests/accounting.rs index ee7ec629..cd5a7985 100644 --- a/crates/gamut-png/tests/accounting.rs +++ b/crates/gamut-png/tests/accounting.rs @@ -583,8 +583,10 @@ fn the_chunk_ceiling_admits_exactly_its_own_count_and_refuses_one_more() { ); // IHDR is a chunk and is counted like one. The walk pushes it before the loop that reads the - // rest, so a ceiling checked only inside that loop let it through and `with_max_chunks(N)` - // meant N + 1 in that one respect — visibly so at zero, which admitted a whole file. + // rest, so a ceiling checked only inside that loop still counted it — the boundary above + // holds either way — but never ran at all for a datastream that pushes no chunk after IHDR. + // Such a file escaped the ceiling however small it was, which is what this case pins: one + // chunk fits a ceiling of one, and nothing fits a ceiling of zero. let ihdr_only = common::png_from_chunks(&chunks[..1]); let report = deconstruct_with_limits(&ihdr_only, DeconstructLimits::default().with_max_chunks(1)) From cce9939c7bed83dce3b1c7a22ab3c32bd99a4c55 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 9 Sep 2026 13:47:29 -0400 Subject: [PATCH 66/94] docs(png): state the tie-break rule once, without appealing to encode order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two candidate races resolved ties "toward the one the encoder already emitted before the runner-up joined the race", which makes the contract a function of implementation history: reorder the encodes and the documented behaviour changes under it. The module doc now states one rule the three tie-breaks answer to. At equal size prefer the candidate that discards less of the input's information — transparent cleanup is the crate's one lossy knob, opt-in for a size win, so with no win the byte-exact candidate stands. Where the candidates are information-equivalent, as every lossless reduction is, nothing distinguishes them at equal size and the fixed order `chunked ≻ chunk-free ≻ native` exists only to make the output a function of the input, which `size_contract::encoded_size_is_deterministic` is what pins. `prefers_plain`, `prefers_chunk_free`, `prefers_native` and `write_reduced_or_native` now each name their half of that rule and link to it. No behaviour change. --- crates/gamut-png/src/encoder.rs | 49 ++++++++++++++++++++++++--------- 1 file changed, 36 insertions(+), 13 deletions(-) diff --git a/crates/gamut-png/src/encoder.rs b/crates/gamut-png/src/encoder.rs index aff9cef1..dd4ed258 100644 --- a/crates/gamut-png/src/encoder.rs +++ b/crates/gamut-png/src/encoder.rs @@ -1,6 +1,23 @@ //! The PNG encoder: a [`PngEncoder`] builder implementing [`gamut_core::EncodeImage`] for each //! supported pixel layout. This covers the four non-indexed colour types at 8- and 16-bit depth; //! palette, sub-byte depths, ancillary chunks, and space optimisations layer on in later phases. +//! +//! # How a tie is broken +//! +//! Several candidate encodings are raced and the smallest kept ([`PngEncoder::cleaned_or_plain`], +//! [`PngEncoder::write_reduced_or_native`]). At *equal* size the size contract cannot choose, so +//! one rule decides all three tie-breaks: +//! +//! 1. **Prefer the candidate that discards less of the input's information.** Transparent cleanup +//! is this crate's one lossy knob — it rewrites samples no decoder renders — and it is opt-in +//! for a size win; with no win there is nothing to trade the exactness for, so the byte-exact +//! candidate stands ([`prefers_plain`]). +//! 2. **Where the candidates are information-equivalent, fall back to a fixed order:** +//! `chunked ≻ chunk-free ≻ native` ([`prefers_chunk_free`], [`prefers_native`]). Every lossless +//! reduction preserves exactly the same image, so nothing distinguishes them at equal size; the +//! order exists only so that the output is a function of the input rather than of which +//! candidate happened to be encoded first. `tests/size_contract.rs`'s +//! `encoded_size_is_deterministic` is what pins that. use gamut_core::{ Bilevel, Dimensions, EncodeImage, Error, Gray8, Gray16, GrayAlpha8, GrayAlpha16, ImageRef, @@ -660,10 +677,11 @@ impl PngEncoder { /// all three are measured — `tests/size_contract.rs`'s `opaque256_rgba8` and /// `demotable_rgb16` rows are those two cases. /// - /// **The total order.** Ties resolve toward the earlier of `chunked ≻ chunk-free ≻ native` — - /// the more reduced encoding, and, among equal-length files, the one the encoder already - /// emitted before the runner-up joined the race, so a tie changes no output. See - /// [`prefers_chunk_free`] and [`prefers_native`], where each step is stated on its own. + /// **The total order.** All three candidates here are lossless, so at equal size none is + /// better by any property the size contract can see; ties resolve toward the earlier of + /// `chunked ≻ chunk-free ≻ native` purely so that the output is a function of the input. See + /// [the module's tie-break rule](self#how-a-tie-is-broken), and [`prefers_chunk_free`] / + /// [`prefers_native`], where each step is stated on its own. /// /// Only a reduction that *carries a chunk* pays for the extra encodes — a palette's `PLTE` /// (+ `tRNS`), or a colour key's `tRNS`. A chunk-free winner adds nothing DEFLATE cannot @@ -842,10 +860,12 @@ impl PngEncoder { /// Whether the uncleaned encoding beats the cleaned one, for [`PngEncoder::cleaned_or_plain`]. /// -/// **A tie keeps the plain encoding.** Every other reduction in this crate is byte-exact; -/// [`with_transparent_cleanup`](PngEncoder::with_transparent_cleanup) is the one knob that alters -/// stored samples, and it is opt-in *for a size win*. Where there is no size win there is nothing -/// to trade the exactness for, so the candidate that changed no sample is kept. Split out for the +/// **A tie keeps the plain encoding.** This is the first half of +/// [the module's tie-break rule](self#how-a-tie-is-broken): the two candidates are *not* +/// information-equivalent, and the one that discards less wins. Every other reduction in this +/// crate is byte-exact; [`with_transparent_cleanup`](PngEncoder::with_transparent_cleanup) is the +/// one knob that alters stored samples, and it is opt-in *for a size win*. Where there is no size +/// win there is nothing to trade the exactness for. 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. @@ -856,9 +876,10 @@ fn prefers_plain(plain_len: usize, cleaned_len: usize) -> bool { /// Whether the chunk-free reduction beats the chunk-carrying one, the first step of /// [`PngEncoder::write_reduced_or_native`]'s three-way race. /// -/// **A tie keeps the chunk-carrying encoding**: it is the candidate the raw estimate ranked first -/// and the one the encoder emitted before the runner-up joined the race, so an equal-length -/// runner-up changes no output. Split out for the same reason as [`prefers_native`]. +/// **A tie keeps the chunk-carrying encoding.** Both candidates are lossless and encode the same +/// image, so at equal size neither is better; the fixed order is what makes the choice +/// deterministic — see [the module's tie-break rule](self#how-a-tie-is-broken). Split out for the +/// same reason as [`prefers_native`]. fn prefers_chunk_free(chunk_free_len: usize, chunked_len: usize) -> bool { chunk_free_len < chunked_len } @@ -866,8 +887,10 @@ fn prefers_chunk_free(chunk_free_len: usize, chunked_len: usize) -> bool { /// Whether the unreduced encoding beats the winning reduction, for /// [`PngEncoder::write_reduced_or_native`]. /// -/// **A tie keeps the reduction**, which decodes with less work for the same bytes — and where the -/// palette won the first step, a tie here keeps the palette. Split out because engineering two +/// **A tie keeps the reduction** — and where the palette won the first step, a tie here keeps the +/// palette. Both candidates are lossless, so the fixed order is what makes the choice +/// deterministic rather than a property of the winner; see +/// [the module's tie-break rule](self#how-a-tie-is-broken). 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 { From cb30ea80fd98bc0fe945107b1ff0cc172e59a60a Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 9 Sep 2026 14:11:39 -0400 Subject: [PATCH 67/94] fix(png): end the C2PA store walk with the datastream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `find_c2pa` walked to end of input, unlike every other chunk walk in the crate (`parse_stream`, `walk_metadata_chunks` and `deconstruct` all stop at IEND). Latent today, since its only caller is the encoder reading back its own fresh output, but it made the "the encoder's report and the file's report cannot disagree" claim false for any later caller: a `caBX` appended after IEND — bytes §13.2 calls a trailer, outside the datastream — would have been reported as the file's manifest store. The walk now stops at the first IDAT or at IEND, whichever comes first, which states the store rule in one place: the first CRC-valid `caBX` before the first IDAT. Stopping at IDAT is C2PA 2.4 §A.3.2's placement — the store precedes IDAT and data after it is bad-form — and it is what keeps a chunk appended to a finished file from being taken as a store the file does not carry. --- crates/gamut-png/src/chunk.rs | 54 +++++++++++++++++++++++++++++++---- 1 file changed, 49 insertions(+), 5 deletions(-) diff --git a/crates/gamut-png/src/chunk.rs b/crates/gamut-png/src/chunk.rs index 4ef6e715..9efefa37 100644 --- a/crates/gamut-png/src/chunk.rs +++ b/crates/gamut-png/src/chunk.rs @@ -86,15 +86,30 @@ impl C2paSpan { } } -/// Locates the manifest store in a PNG: the first CRC-valid `caBX` chunk, or `None`. +/// Locates the manifest store in a PNG: the first CRC-valid `caBX` chunk **before the first +/// `IDAT`**, or `None`. /// -/// The first CRC-valid one, because that is the chunk the decoder surfaces as its `c2pa` payload -/// (§13.1 skips a CRC mismatch), so the span a caller excludes from a hash is the store it reads. -/// Stops at end of input or at the first chunk that does not frame; a stream that is not a PNG -/// has no store. +/// The one definition of "the store", shared by every reader in this crate so they cannot +/// disagree — [`PngReport::c2pa`](crate::PngReport::c2pa) and the decoder's metadata walks apply +/// the same rule. Three parts, each load-bearing: +/// +/// - **CRC-valid**, because §13.1 makes a mismatching ancillary chunk skippable, and the decoder +/// skips it — so a span a caller excludes from a hash names the store the decoder read; +/// - **the first**, because a PNG carries exactly one store (C2PA 2.4 §A.3.2); a later one is a +/// malformed file's extra chunk, never merged in; +/// - **before the first `IDAT`**, because §A.3.2 places the store there and calls data after +/// `IDAT` bad-form. A `caBX` appended to a finished file is therefore not the store, which is +/// what stops an appender turning a file that carries none into one that appears to. +/// +/// The walk stops at the first `IDAT` or at `IEND`, whichever comes first: `IEND` ends the +/// datastream (§5.6), and bytes after it are a trailer, not chunks (§13.2). It also stops at the +/// first chunk that does not frame, so a stream that is not a PNG simply has no store. pub(crate) fn find_c2pa(png: &[u8]) -> Option { let mut reader = ChunkReader::new(png).ok()?; while let Ok(Some(chunk)) = reader.next_chunk() { + if chunk.chunk_type == *b"IDAT" || chunk.chunk_type == *b"IEND" { + return None; + } if chunk.chunk_type == CABX && chunk.crc_ok { return Some(C2paSpan::of(chunk.range)); } @@ -299,6 +314,35 @@ mod tests { assert_eq!(&png[span.chunk.end + 4..span.chunk.end + 8], b"IEND"); } + /// The walk ends with the datastream. A `caBX` after `IDAT` is bad-form carriage (C2PA + /// §A.3.2) and one after `IEND` is not in the datastream at all (§13.2) — neither is the + /// store, so an appender cannot inject one into a file that carries none. + #[test] + fn find_c2pa_stops_at_the_first_idat_and_at_iend() { + let mut after_idat = SIGNATURE.to_vec(); + write_chunk(&mut after_idat, *b"IHDR", &[0; 13]); + write_chunk(&mut after_idat, *b"IDAT", b"zz"); + write_chunk(&mut after_idat, CABX, b"appended"); + write_chunk(&mut after_idat, *b"IEND", &[]); + assert_eq!(find_c2pa(&after_idat), None, "a caBX after IDAT is not the store"); + + let mut after_iend = SIGNATURE.to_vec(); + write_chunk(&mut after_iend, *b"IHDR", &[0; 13]); + write_chunk(&mut after_iend, *b"IEND", &[]); + write_chunk(&mut after_iend, CABX, b"trailing"); + assert_eq!(find_c2pa(&after_iend), None, "a caBX after IEND is not the store"); + + // ...while the same chunk one position earlier — before IDAT — is the store, so the + // stop is what decides, not the payload. + let mut before_idat = SIGNATURE.to_vec(); + write_chunk(&mut before_idat, *b"IHDR", &[0; 13]); + write_chunk(&mut before_idat, CABX, b"appended"); + write_chunk(&mut before_idat, *b"IDAT", b"zz"); + write_chunk(&mut before_idat, *b"IEND", &[]); + let span = find_c2pa(&before_idat).expect("a store before IDAT"); + assert_eq!(&before_idat[span.payload], b"appended"); + } + /// The store the span names is the one the decoder reads: a `caBX` whose CRC does not match /// is skipped on decode (§13.1), so it is skipped here too, and the CRC-valid one after it /// is the store. A stream with no `caBX`, or no signature, has none. From 03424733fe634cbbab9eeb4006d2232a146be3a9 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 9 Sep 2026 14:13:13 -0400 Subject: [PATCH 68/94] feat(png): fill a reserved C2PA store in place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `fill_c2pa(&mut png, &span, store)` writes a finished manifest store into the `caBX` chunk a span names, rewriting the payload and the chunk CRC and nothing else. It is the second half of the reserve-then-fill flow C2PA 2.4 §18.5 describes, and the shape that flow actually needs: a signer hashes the reserved file with the chunk's span excluded, then fills it. Until now the only way to fill a reservation was to encode again with `with_c2pa`. That reaches the same bytes but costs a second full encode — at `Level::Best` with `FilterStrategy::BruteForce`, the whole brute-force set again — and it makes the signature depend on the encoder reproducing its output byte for byte. Filling in place is O(store) and depends on nothing but the chunk's own bytes, and it is the only route at all for a file gamut did not write. Every argument is validated before the first byte is written, with a distinct typed error each: the span must lie inside the image, frame a chunk (payload exactly `chunk.start + 8 .. chunk.end - 4`), name a `caBX`, and receive a store of exactly the reserved length. A store of the wrong length is rejected rather than resized: resizing would move every byte after the chunk and invalidate the hash the signer signed. --- crates/gamut-png/src/chunk.rs | 177 ++++++++++++++++++++++++++++++++++ crates/gamut-png/src/lib.rs | 7 +- 2 files changed, 181 insertions(+), 3 deletions(-) diff --git a/crates/gamut-png/src/chunk.rs b/crates/gamut-png/src/chunk.rs index 9efefa37..75861bb8 100644 --- a/crates/gamut-png/src/chunk.rs +++ b/crates/gamut-png/src/chunk.rs @@ -202,6 +202,93 @@ impl<'a> ChunkReader<'a> { } } +/// Writes a finished C2PA manifest store into the `caBX` chunk `span` names, **in place**. +/// +/// The second half of the reserve-then-fill flow (C2PA 2.4 §18.5): encode once with +/// [`PngEncoder::with_c2pa_reserved`](crate::PngEncoder::with_c2pa_reserved), hash the output with +/// `span.chunk` excluded, have the signer build a store of exactly the reserved length, then call +/// this. Only the payload and the chunk's CRC change; the length field, the type, and every byte +/// outside `span.chunk` — every offset in the file — are untouched, so the hash the signer signed +/// still describes the filled file. +/// +/// **This is the supported way to put a store into a file this encoder is not re-encoding**, and +/// the only one for a file gamut did not write. Re-encoding with +/// [`PngEncoder::with_c2pa`](crate::PngEncoder::with_c2pa) reaches the same bytes, but it costs a +/// second full encode (at [`Level::Best`](crate::Level) with +/// [`FilterStrategy::BruteForce`](crate::FilterStrategy) that is the whole brute-force set again) +/// and it makes the signature depend on the encoder reproducing its output byte for byte. Filling +/// in place depends on nothing but these twelve-plus-`n` bytes. +/// +/// The span comes from [`PngEncodeReport::c2pa`](crate::PngEncodeReport::c2pa) for a file this +/// encoder just wrote, or from [`PngReport::c2pa`](crate::PngReport::c2pa) for any file — which is +/// also the route for an indexed image, since +/// [`encode_indexed8`](crate::PngEncoder::encode_indexed8) has no report of its own. +/// +/// # Errors +/// +/// Returns [`Error::InvalidInput`] and leaves `png` **unmodified** if `span` runs past the end of +/// `png`, if it does not frame a chunk (its payload must be `chunk.start + 8 .. chunk.end - 4`), +/// if the bytes it names are not a `caBX` chunk, or if `store` is not exactly the reserved +/// length. Every check runs before the first byte is written, so a rejected call cannot leave a +/// half-filled chunk behind — and a store of the wrong length is rejected rather than resized, +/// because resizing would move every byte after the chunk and invalidate the signer's hash. +/// +/// # Example +/// +/// ``` +/// use gamut_core::{Dimensions, EncodeImage, ImageRef, Rgb8}; +/// use gamut_png::{PngEncoder, fill_c2pa}; +/// +/// # fn main() -> Result<(), Box> { +/// let pixels = vec![0u8; 3 * 4]; +/// let image = ImageRef::::new(&pixels, Dimensions::new(2, 2)?)?; +/// let (mut png, report) = PngEncoder::new() +/// .with_c2pa_reserved(16) +/// .encode_with_report(image)?; +/// let span = report.c2pa.expect("a reservation was made"); +/// +/// // ... hash `png` with `span.chunk` excluded, sign, and receive a 16-byte store ... +/// fill_c2pa(&mut png, &span, &[7u8; 16])?; +/// +/// assert_eq!(gamut_png::metadata(&png)?.c2pa.as_deref(), Some(&[7u8; 16][..])); +/// # Ok(()) +/// # } +/// ``` +pub fn fill_c2pa(png: &mut [u8], span: &C2paSpan, store: &[u8]) -> Result<()> { + let invalid = |message: &'static str| Error::invalid_input(env!("CARGO_PKG_NAME"), message); + if span.chunk.end > png.len() { + return Err(invalid("PNG: the C2PA span runs past the end of the image")); + } + // The span must frame a chunk: 4 length bytes and 4 type bytes ahead of the payload, 4 CRC + // bytes behind it. Checked rather than assumed because a caller can build a `C2paSpan`. + let frames = span + .chunk + .start + .checked_add(8) + .zip(span.chunk.end.checked_sub(4)) + .is_some_and(|(payload_start, payload_end)| { + span.payload.start == payload_start + && span.payload.end == payload_end + && payload_start <= payload_end + }); + if !frames { + return Err(invalid("PNG: the C2PA span does not frame a chunk")); + } + if png[span.chunk.start + 4..span.payload.start] != CABX { + return Err(invalid("PNG: the C2PA span does not name a caBX chunk")); + } + if store.len() != span.payload.len() { + return Err(invalid("PNG: the C2PA store is not the reserved length")); + } + + png[span.payload.clone()].copy_from_slice(store); + let mut crc = Crc32::new(); + crc.update(&CABX); + crc.update(store); + png[span.payload.end..span.chunk.end].copy_from_slice(&crc.finish().to_be_bytes()); + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -314,6 +401,96 @@ mod tests { assert_eq!(&png[span.chunk.end + 4..span.chunk.end + 8], b"IEND"); } + /// A PNG carrying a `len`-byte reservation, plus the span naming it. + fn reserved(len: usize) -> (Vec, C2paSpan) { + let mut png = SIGNATURE.to_vec(); + write_chunk(&mut png, *b"IHDR", &[0; 13]); + write_chunk(&mut png, CABX, &vec![0; len]); + write_chunk(&mut png, *b"IDAT", b"zz"); + write_chunk(&mut png, *b"IEND", &[]); + let span = find_c2pa(&png).expect("a reservation"); + (png, span) + } + + /// Filling rewrites the payload and the CRC, and nothing else: every byte outside the span + /// is untouched, the length and type inside it are untouched, and the chunk still frames — + /// `find_c2pa` re-reads the filled store, which it can only do if the CRC was recomputed. + #[test] + fn filling_a_reservation_rewrites_the_payload_and_its_crc_alone() { + let (mut png, span) = reserved(6); + let before = png.clone(); + fill_c2pa(&mut png, &span, b"jumbf!").expect("fill"); + + assert_eq!(&png[span.payload.clone()], b"jumbf!"); + assert_eq!(png.len(), before.len()); + for i in (0..png.len()).filter(|i| !span.chunk.contains(i)) { + assert_eq!(png[i], before[i], "byte {i} outside the span changed"); + } + assert_eq!( + png[span.chunk.start..span.payload.start], + before[span.chunk.start..span.payload.start], + "the length and type fields are untouched" + ); + assert_ne!( + png[span.payload.end..span.chunk.end], + before[span.payload.end..span.chunk.end], + "the CRC followed the payload" + ); + // The CRC is not merely different, it is right: the walk only returns a CRC-valid chunk. + let refound = find_c2pa(&png).expect("the filled chunk still verifies"); + assert_eq!(refound, span); + assert_eq!(&png[refound.payload], b"jumbf!"); + } + + /// Every argument is validated before a byte is written, each with its own message, and a + /// rejected call leaves the image exactly as it was. + #[test] + fn filling_validates_its_span_and_length_before_writing() { + let (png, span) = reserved(4); + + let mut short = png.clone(); + let error = fill_c2pa(&mut short, &span, b"abc").expect_err("one byte short"); + assert!( + error.to_string().contains("not the reserved length"), + "{error}" + ); + assert_eq!(short, png, "a rejected fill writes nothing"); + let mut long = png.clone(); + assert!(fill_c2pa(&mut long, &span, b"abcde").is_err(), "one byte long"); + assert_eq!(long, png); + + // A span past the end of the buffer. + let mut truncated = png[..span.chunk.end - 1].to_vec(); + let error = fill_c2pa(&mut truncated, &span, b"abcd").expect_err("past the end"); + assert!(error.to_string().contains("runs past the end"), "{error}"); + + // A span whose payload does not sit inside its framing. + let mut mine = png.clone(); + let skewed = C2paSpan { + chunk: span.chunk.clone(), + payload: span.payload.start + 1..span.payload.end, + }; + let error = fill_c2pa(&mut mine, &skewed, b"abc").expect_err("not framed"); + assert!(error.to_string().contains("does not frame a chunk"), "{error}"); + assert_eq!(mine, png); + + // A well-framed span naming some other chunk: the IHDR right before it. + let ihdr = C2paSpan::of(8..8 + 12 + 13); + let error = fill_c2pa(&mut mine, &ihdr, &[0; 13]).expect_err("not a caBX"); + assert!(error.to_string().contains("does not name a caBX"), "{error}"); + assert_eq!(mine, png); + } + + /// A zero-length reservation is a legal chunk, and filling it with nothing is a no-op that + /// still verifies — the boundary where payload start and end coincide. + #[test] + fn filling_an_empty_reservation_is_lawful() { + let (mut png, span) = reserved(0); + assert_eq!(span.payload.len(), 0); + fill_c2pa(&mut png, &span, &[]).expect("fill"); + assert_eq!(find_c2pa(&png), Some(span)); + } + /// The walk ends with the datastream. A `caBX` after `IDAT` is bad-form carriage (C2PA /// §A.3.2) and one after `IEND` is not in the datastream at all (§13.2) — neither is the /// store, so an appender cannot inject one into a file that carries none. diff --git a/crates/gamut-png/src/lib.rs b/crates/gamut-png/src/lib.rs index 74def69f..9ddb73a7 100644 --- a/crates/gamut-png/src/lib.rs +++ b/crates/gamut-png/src/lib.rs @@ -28,8 +28,9 @@ //! store computed for this file and [`PngEncoder::with_c2pa_reserved`] reserves its place, as the //! last chunk before `IDAT`; [`PngEncoder::encode_with_report`] and [`PngReport::c2pa`] name the //! chunk's **whole** span — length, type, payload and CRC — which is what a `c2pa.hash.data` -//! assertion excludes (§18.5.4), and a reservation is filled by a second encode of equal length -//! that changes no byte outside it. +//! assertion excludes (§18.5.4). [`fill_c2pa`] then writes the finished store into that span in +//! place, rewriting only the payload and the chunk CRC, so the file the signer hashed is the file +//! it signed. //! //! # Pluggable IDAT backends //! @@ -86,7 +87,7 @@ pub mod stages; pub use abi::{AbiDeflater, AbiInflater, CODEC_ID_ZLIB, PIXEL_FORMAT_FILTERED_BYTES}; pub use ancillary::{PhysicalUnit, SrgbIntent}; pub use backend::{IdatDeflater, IdatInflater, IdatInfo}; -pub use chunk::C2paSpan; +pub use chunk::{C2paSpan, fill_c2pa}; pub use color::ColorType; pub use decoded::{ Chromaticities, Cicp, DecodedPng, IccProfile, PngHeader, PngImage, PngMetadata, TextChunk, From 704fa2538a42fd4d5de9711d8f6a00f56c61ea8a Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 9 Sep 2026 14:19:40 -0400 Subject: [PATCH 69/94] fix(png): never read a caBX after IDAT as the manifest store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C2PA 2.4 §A.3.2 places the manifest store before IDAT and calls data after it bad-form, but the decode surfaced the first CRC-valid `caBX` wherever it sat. That let anyone append a `caBX` to a finished PNG and have it read back as that file's provenance — including into a file that carries no store at all, where the appended chunk became the only answer. The encoder never writes there, so nothing gamut produces was affected; the exposure was on read. The store is now the first CRC-valid `caBX` before the first IDAT, in all three readers that answer the question — `decode`, both `metadata` entry points, and `PngReport::c2pa` — with the addition done in one place so they cannot drift apart. An ignored chunk stays visible rather than being silently dropped: the counter now covers both reasons a `caBX` is not the store, a later one and one after IDAT, so `c2pa == None` with a non-zero count is exactly the shape of an appended store. It is renamed `c2pa_ignored` to say that, since "duplicates" is false when the file carries no original, and it becomes a `usize`: as a saturating `u8` a file with 300 ignored chunks reported 255, a number the file does not contain. --- crates/gamut-png/src/decoded.rs | 64 ++++++++----- crates/gamut-png/src/decoder.rs | 79 ++++++++++++--- crates/gamut-png/src/deconstruct.rs | 70 +++++++++----- crates/gamut-png/tests/c2pa.rs | 144 ++++++++++++++++++++++++++-- crates/gamut-png/tests/metadata.rs | 2 +- 5 files changed, 290 insertions(+), 69 deletions(-) diff --git a/crates/gamut-png/src/decoded.rs b/crates/gamut-png/src/decoded.rs index 32114a7e..b3a593c5 100644 --- a/crates/gamut-png/src/decoded.rs +++ b/crates/gamut-png/src/decoded.rs @@ -147,14 +147,19 @@ pub struct DecodedPng { pub xmp: Option>, /// The C2PA manifest store (the `caBX` chunk, C2PA 2.4 §A.3.2) verbatim: the JUMBF bytes, /// uncompressed, exactly as the chunk carries them — opaque here, never parsed or judged. - /// Feed as `MetadataBlock::C2pa`. The first `caBX` in the file, and only when it fits the - /// metadata budget; see [`c2pa_duplicates`](Self::c2pa_duplicates). + /// Feed as `MetadataBlock::C2pa`. The first CRC-valid `caBX` before the first `IDAT`, and + /// only when it fits the metadata budget; see [`c2pa_ignored`](Self::c2pa_ignored). pub c2pa: Option>, - /// How many further `caBX` chunks followed the first, saturating at 255. A file carries - /// exactly one manifest store — PNG has no multi-chunk store, unlike JPEG's APP11 run — so - /// any value above zero marks a malformed file whose extra stores were ignored rather than - /// concatenated. - pub c2pa_duplicates: u8, + /// How many CRC-valid `caBX` chunks the file carries that were **not** surfaced as the + /// store: any after the first, and any positioned after `IDAT`. + /// + /// A file carries exactly one manifest store — PNG has no multi-chunk store, unlike JPEG's + /// APP11 run — so a non-zero count marks a malformed file whose extra chunks were ignored + /// rather than concatenated. The post-`IDAT` case is worth its own attention: §A.3.2 places + /// the store before `IDAT` and calls data after it bad-form, so a `caBX` appended to a + /// finished file is never read as the store. A file whose `c2pa` is `None` while this is + /// non-zero is exactly that shape — someone appended a store to a file that carries none. + pub c2pa_ignored: usize, /// tEXt/zTXt/iTXt annotations in file order (the XMP packet is excluded). pub texts: Vec, /// gAMA: image gamma × 100 000 (§11.3.2.2) — the unit the encoder's `with_gamma` writes. @@ -213,15 +218,20 @@ pub struct PngMetadata { /// compressed. Feed as `MetadataBlock::Xmp`. pub xmp: Option>, /// The C2PA manifest store (the `caBX` chunk, C2PA 2.4 §A.3.2) verbatim and uncompressed — - /// opaque bytes, never parsed or judged. Feed as `MetadataBlock::C2pa`. The first `caBX` in - /// the file, and only when it fits the metadata budget; see - /// [`c2pa_duplicates`](Self::c2pa_duplicates). + /// opaque bytes, never parsed or judged. Feed as `MetadataBlock::C2pa`. The first CRC-valid + /// `caBX` before the first `IDAT`, and only when it fits the metadata budget; see + /// [`c2pa_ignored`](Self::c2pa_ignored). pub c2pa: Option>, - /// How many further `caBX` chunks followed the first, saturating at 255. A file carries - /// exactly one manifest store — PNG has no multi-chunk store, unlike JPEG's APP11 run — so - /// any value above zero marks a malformed file whose extra stores were ignored rather than - /// concatenated. - pub c2pa_duplicates: u8, + /// How many CRC-valid `caBX` chunks the file carries that were **not** surfaced as the + /// store: any after the first, and any positioned after `IDAT`. + /// + /// A file carries exactly one manifest store — PNG has no multi-chunk store, unlike JPEG's + /// APP11 run — so a non-zero count marks a malformed file whose extra chunks were ignored + /// rather than concatenated. The post-`IDAT` case is worth its own attention: §A.3.2 places + /// the store before `IDAT` and calls data after it bad-form, so a `caBX` appended to a + /// finished file is never read as the store. A file whose `c2pa` is `None` while this is + /// non-zero is exactly that shape — someone appended a store to a file that carries none. + pub c2pa_ignored: usize, /// tEXt/zTXt/iTXt annotations in file order (the XMP packet is excluded). pub texts: Vec, /// gAMA: image gamma × 100 000 (§11.3.2.2). @@ -239,6 +249,11 @@ pub struct PngMetadata { /// attacker-sized `caBX` store — share `budget` bytes of output, and a payload that would bust /// the remainder is skipped, not an error. Once-only chunks keep their first occurrence; a /// second `caBX` is additionally counted, since exactly one store is the rule (C2PA §A.3.2). +/// +/// `chunks` holds only chunks in a position where a store may appear: the caller's walk drops a +/// `caBX` after `IDAT` before it gets here and counts it into +/// [`PngMetadata::c2pa_ignored`](PngMetadata::c2pa_ignored) itself, so this function never has to +/// know where in the file a chunk sat. pub(crate) fn collect(chunks: &[([u8; 4], &[u8])], budget: usize) -> PngMetadata { let mut meta = PngMetadata::default(); let mut budget = budget; @@ -250,7 +265,7 @@ pub(crate) fn collect(chunks: &[([u8; 4], &[u8])], budget: usize) -> PngMetadata b"eXIf" if meta.exif.is_none() => meta.exif = Some(data.to_vec()), _ if chunk_type == CABX => { if seen_c2pa { - meta.c2pa_duplicates = meta.c2pa_duplicates.saturating_add(1); + meta.c2pa_ignored += 1; } else { seen_c2pa = true; if data.len() <= budget { @@ -569,21 +584,22 @@ mod tests { 1024, ); assert_eq!(meta.c2pa.as_deref(), Some(&b"first store"[..])); - assert_eq!(meta.c2pa_duplicates, 2); + assert_eq!(meta.c2pa_ignored, 2); let single = collect(&[(CABX, b"only")], 1024); assert_eq!(single.c2pa.as_deref(), Some(&b"only"[..])); - assert_eq!(single.c2pa_duplicates, 0); + assert_eq!(single.c2pa_ignored, 0); assert_eq!(collect(&[], 1024).c2pa, None); } - /// The duplicate count saturates rather than wrapping: 256 further stores read as 255, not - /// as none at all. + /// The count is a plain `usize`: it reports what the file carries however many that is. A + /// saturating `u8` here read 300 ignored chunks as 255, which is a number the file does not + /// contain. #[test] - fn the_cabx_duplicate_count_saturates_at_255() { - let chunks: Vec<([u8; 4], &[u8])> = (0..257).map(|_| (CABX, &b"s"[..])).collect(); + fn the_ignored_count_reports_every_chunk_not_a_saturated_ceiling() { + let chunks: Vec<([u8; 4], &[u8])> = (0..301).map(|_| (CABX, &b"s"[..])).collect(); let meta = collect(&chunks, 1024); - assert_eq!(meta.c2pa_duplicates, 255); + assert_eq!(meta.c2pa_ignored, 300); } /// `caBX` is attacker-sized like every other ancillary payload, so it is charged to the one @@ -609,7 +625,7 @@ mod tests { let busts = collect(&[(CABX, &store), (CABX, b"tiny")], 9); assert_eq!(busts.c2pa, None, "one byte over the budget is skipped"); assert_eq!( - busts.c2pa_duplicates, 1, + busts.c2pa_ignored, 1, "the skipped store is still the first; the next is a duplicate, not a substitute" ); } diff --git a/crates/gamut-png/src/decoder.rs b/crates/gamut-png/src/decoder.rs index 0f4c7222..4441d261 100644 --- a/crates/gamut-png/src/decoder.rs +++ b/crates/gamut-png/src/decoder.rs @@ -26,7 +26,7 @@ use gamut_core::{ }; use crate::backend::{IdatInflater, IdatInfo, Registry, run_inflaters}; -use crate::chunk::ChunkReader; +use crate::chunk::{CABX, ChunkReader}; use crate::color::ColorType; use crate::decoded::{self, DecodedPng, PngHeader, PngImage, PngMetadata}; use crate::filter::{self, FilterType}; @@ -179,8 +179,14 @@ struct Parsed<'a> { trns: Option<&'a [u8]>, /// All IDAT payloads, concatenated (§5.6 requires them consecutive). idat: Vec, - /// Metadata-bearing ancillary chunks in file order (populated only when requested). + /// Metadata-bearing ancillary chunks in file order (populated only when requested), holding + /// only chunks in a position where a manifest store may appear — see `c2pa_after_idat`. ancillary: Vec<([u8; 4], &'a [u8])>, + /// CRC-valid `caBX` chunks found *after* `IDAT`. C2PA §A.3.2 places the store before `IDAT` + /// and calls data after it bad-form, so these are never the store; they are counted rather + /// than dropped silently, because a `caBX` appended to a finished file is what an injection + /// attempt looks like. + c2pa_after_idat: usize, } /// Decoded samples in the file's native value range: one byte per sample for depths ≤ 8 @@ -225,6 +231,7 @@ impl PngDecoder { let mut trns: Option<&[u8]> = None; let mut idat = Vec::new(); let mut ancillary = Vec::new(); + let mut c2pa_after_idat = 0usize; let mut seen_idat = false; let mut idat_done = false; let mut seen_iend = false; @@ -322,7 +329,14 @@ impl PngDecoder { // These are borrowed slices, so an unrecognised chunk costs a fat pointer, // not a copy of its payload. if want_metadata && chunk.crc_ok { - ancillary.push((chunk.chunk_type, chunk.data)); + // The one chunk type whose *position* decides whether it is metadata at + // all: a manifest store precedes IDAT (§A.3.2), so one after IDAT is + // counted as ignored rather than offered as the store. + if chunk.chunk_type == CABX && seen_idat { + c2pa_after_idat += 1; + } else { + ancillary.push((chunk.chunk_type, chunk.data)); + } } } _ => { @@ -353,6 +367,7 @@ impl PngDecoder { trns, idat, ancillary, + c2pa_after_idat, }) } @@ -399,7 +414,11 @@ impl PngDecoder { /// payloads are not errors: the affected chunk is skipped (§13.1) and its field stays empty. pub fn decode(&self, data: &[u8]) -> Result { let parsed = self.parse_stream(data, true)?; - let meta = decoded::collect(&parsed.ancillary, self.max_metadata_bytes); + let meta = collected( + &parsed.ancillary, + self.max_metadata_bytes, + parsed.c2pa_after_idat, + ); let native = self.decode_parsed(&parsed)?; let header = PngHeader { width: native.header.width, @@ -418,7 +437,7 @@ impl PngDecoder { icc_profile: meta.icc_profile, xmp: meta.xmp, c2pa: meta.c2pa, - c2pa_duplicates: meta.c2pa_duplicates, + c2pa_ignored: meta.c2pa_ignored, texts: meta.texts, gamma: meta.gamma, chromaticities: meta.chromaticities, @@ -458,8 +477,12 @@ impl PngDecoder { /// # } /// ``` pub fn metadata(&self, data: &[u8]) -> Result { - let chunks = walk_metadata_chunks(data)?; - Ok(decoded::collect(&chunks, self.max_metadata_bytes)) + let (chunks, c2pa_after_idat) = walk_metadata_chunks(data)?; + Ok(collected( + &chunks, + self.max_metadata_bytes, + c2pa_after_idat, + )) } /// Runs the typed pipeline: parse (without metadata) → decode. @@ -536,7 +559,11 @@ impl PngDecoder { /// Deliberately not `parse_stream` itself: that accumulates every IDAT payload into an owned /// `Vec` and then requires at least one, neither of which a metadata read should do. Here IDAT /// (and PLTE) is skipped by length, so the pixel data is never touched or copied. -fn walk_metadata_chunks(data: &[u8]) -> Result> { +/// +/// Returns the chunks together with the number of CRC-valid `caBX` chunks seen *after* `IDAT`, +/// which are never the store (§A.3.2) and so are counted rather than returned — the same split +/// [`PngDecoder::parse_stream`] makes, so the two entry points agree on what the store is. +fn walk_metadata_chunks(data: &[u8]) -> Result<(Vec<([u8; 4], &[u8])>, usize)> { let mut reader = ChunkReader::new(data)?; let first = reader .next_chunk()? @@ -558,6 +585,8 @@ fn walk_metadata_chunks(data: &[u8]) -> Result> { ihdr::parse(first.data)?; let mut chunks = Vec::new(); + let mut c2pa_after_idat = 0usize; + let mut seen_idat = false; let mut seen_iend = false; while let Some(chunk) = reader.next_chunk()? { match &chunk.chunk_type { @@ -584,11 +613,16 @@ fn walk_metadata_chunks(data: &[u8]) -> Result> { break; } // The pixel-bearing critical chunks. Skipped by length — never read, never copied. - b"IDAT" | b"PLTE" => {} + b"IDAT" | b"PLTE" => seen_idat |= &chunk.chunk_type == b"IDAT", _ if chunk.is_ancillary() => { // §13.1: a CRC mismatch skips the chunk rather than failing the image. if chunk.crc_ok { - chunks.push((chunk.chunk_type, chunk.data)); + // A store precedes IDAT (§A.3.2); one after it is counted, not offered. + if chunk.chunk_type == CABX && seen_idat { + c2pa_after_idat += 1; + } else { + chunks.push((chunk.chunk_type, chunk.data)); + } } } _ => { @@ -608,7 +642,22 @@ fn walk_metadata_chunks(data: &[u8]) -> Result> { "PNG: missing IEND", )); } - Ok(chunks) + Ok((chunks, c2pa_after_idat)) +} + +/// Assembles the metadata from a walk's two results: the chunks in a store-bearing position, and +/// the count of `caBX` chunks the walk found after `IDAT`. +/// +/// The one place that addition happens, so `decode` and both `metadata` entry points cannot come +/// to report different counts for the same file. +fn collected( + chunks: &[([u8; 4], &[u8])], + budget: usize, + c2pa_after_idat: usize, +) -> PngMetadata { + let mut meta = decoded::collect(chunks, budget); + meta.c2pa_ignored += c2pa_after_idat; + meta } /// Reads a PNG's ancillary metadata without decoding any pixels. @@ -666,8 +715,12 @@ fn walk_metadata_chunks(data: &[u8]) -> Result> { /// # } /// ``` pub fn metadata(data: &[u8]) -> Result { - let chunks = walk_metadata_chunks(data)?; - Ok(decoded::collect(&chunks, DEFAULT_MAX_METADATA_BYTES)) + let (chunks, c2pa_after_idat) = walk_metadata_chunks(data)?; + Ok(collected( + &chunks, + DEFAULT_MAX_METADATA_BYTES, + c2pa_after_idat, + )) } /// Validates PLTE presence/shape and tRNS shape against the colour type (§11.2.2, §11.3.1.1), diff --git a/crates/gamut-png/src/deconstruct.rs b/crates/gamut-png/src/deconstruct.rs index 7601847e..04c07a9d 100644 --- a/crates/gamut-png/src/deconstruct.rs +++ b/crates/gamut-png/src/deconstruct.rs @@ -401,34 +401,58 @@ impl PngReport { ) } - /// The C2PA manifest store's carriage: the whole span of the first CRC-valid `caBX` chunk — - /// length, type, payload **and CRC** — which is what a `c2pa.hash.data` assertion must exclude - /// (C2PA 2.4 §18.5.4): the store's bytes change when it is written, the length field when it - /// is resized, and the CRC with either, so a hash that keeps any of them breaks on the store's - /// first update. The store's own bytes are the span's `payload`. `None` when the file carries - /// no such chunk. + /// Where a C2PA manifest store is **carried**: the whole span of the first CRC-valid `caBX` + /// chunk before the first `IDAT` — length, type, payload **and CRC** — or `None` when the file + /// carries none there. The store's own bytes are the span's `payload`. /// - /// The first CRC-valid one, so this names the chunk [`PngDecoder::decode`] surfaces as its - /// `c2pa` payload (§13.1 skips a CRC mismatch on both sides). A further `caBX` is a malformed - /// file's duplicate — counted by [`chunk`](Self::chunk)`(b"caBX")` and by the decoder's - /// `c2pa_duplicates`, never merged into the span. + /// This is the range a `c2pa.hash.data` assertion must exclude (C2PA 2.4 §18.5.4): the store's + /// bytes change when it is written, the length field when it is resized, and the CRC with + /// either, so a hash that keeps any of them breaks on the store's first update. It is also the + /// span [`fill_c2pa`](crate::fill_c2pa) fills. + /// + /// The rule is [`crate::PngDecoder::decode`]'s: the *first* chunk, since a PNG carries exactly + /// one store (§A.3.2); *CRC-valid*, since §13.1 makes a mismatch skippable; *before `IDAT`*, + /// since data after it is bad-form and an appended chunk must not become the file's store. + /// + /// # This is carriage, not the decoded payload + /// + /// A span here does **not** promise that [`PngDecoder::decode`] surfaced a `c2pa` payload for + /// the same file, and the two answer different questions: + /// + /// - this walk reports what the file *carries*, and has no byte budget; + /// - [`PngDecoder::with_max_metadata_bytes`] bounds what a decode *admits*, so a store past + /// that budget is skipped and `decode().c2pa` is `None` while this still names its span. + /// + /// That is deliberate — a report that hid a chunk because some other reader's budget was too + /// small would not be a byte accounting — but it means a caller must not gate on one and read + /// the other. Exclude the span this returns; read the bytes `decode` returns. + /// + /// For the same reason, this does not agree with [`chunk`](Self::chunk)`(b"caBX")`'s count, + /// which counts every `caBX` including CRC-invalid ones and any after `IDAT`, nor with the + /// decoder's `c2pa_ignored`, which counts only the CRC-valid ones it declined to surface. + /// Each number answers its own question. /// /// [`PngDecoder::decode`]: crate::PngDecoder::decode + /// [`PngDecoder::with_max_metadata_bytes`]: crate::PngDecoder::with_max_metadata_bytes #[must_use] pub fn c2pa(&self) -> Option { - self.segments - .iter() - .find(|segment| { - matches!( - segment.kind, - SegmentKind::Chunk { - chunk_type: CABX, - crc_ok: true, - .. - } - ) - }) - .map(|segment| C2paSpan::of(segment.range.clone())) + for segment in &self.segments { + match segment.kind { + SegmentKind::Chunk { + chunk_type: CABX, + crc_ok: true, + .. + } => return Some(C2paSpan::of(segment.range.clone())), + // The datastream's store sits before the pixels; nothing from here on is one. + SegmentKind::Chunk { chunk_type, .. } + if &chunk_type == b"IDAT" || &chunk_type == b"IEND" => + { + return None; + } + _ => {} + } + } + None } /// The stats for one chunk type, if the file carries it. diff --git a/crates/gamut-png/tests/c2pa.rs b/crates/gamut-png/tests/c2pa.rs index f655018a..29c4c69f 100644 --- a/crates/gamut-png/tests/c2pa.rs +++ b/crates/gamut-png/tests/c2pa.rs @@ -3,8 +3,9 @@ //! moving a byte outside it, and that both reports name the same whole-chunk span at known //! offsets. Differential: libpng frames the same payload into the same bytes, decodes gamut's //! file pixel-exact with the chunk in place, and gamut reads the store back from a libpng-written -//! file. The store is opaque bytes throughout — its behavioural oracle, `c2pa-rs`, is issue -//! #447's. +//! file. It also pins the two rules that decide what *is* the store: exactly one per file, and +//! never one positioned after `IDAT`. The store is opaque bytes throughout — its behavioural +//! oracle, `c2pa-rs`, is issue #447's. mod common; @@ -15,6 +16,7 @@ use common::{ use gamut_core::{DecodeImage, Dimensions, EncodeImage, ImageBuf, ImageRef, Indexed8, Rgb8, Rgba8}; use gamut_png::{ PhysicalUnit, PngDecoder, PngEncoder, PngPalette, SegmentKind, SrgbIntent, deconstruct, + fill_c2pa, }; /// A stand-in manifest store of `len` bytes: not all zero, no two runs alike, so a fill is @@ -279,7 +281,7 @@ fn without_a_store_there_is_no_chunk_and_no_span() { assert_eq!(deconstruct(&png).expect("deconstruct").c2pa(), None); let decoded = PngDecoder::new().decode(&png).expect("decode"); assert_eq!(decoded.c2pa, None); - assert_eq!(decoded.c2pa_duplicates, 0); + assert_eq!(decoded.c2pa_ignored, 0); } /// Both read entry points surface the store byte for byte, and the last of the two setters @@ -295,10 +297,10 @@ fn decode_and_metadata_surface_the_store_verbatim_and_the_last_setter_wins() { .expect("encode"); let meta = gamut_png::metadata(&png).expect("metadata"); assert_eq!(meta.c2pa.as_deref(), Some(&store[..])); - assert_eq!(meta.c2pa_duplicates, 0); + assert_eq!(meta.c2pa_ignored, 0); let decoded = PngDecoder::new().decode(&png).expect("decode"); assert_eq!(decoded.c2pa, meta.c2pa); - assert_eq!(decoded.c2pa_duplicates, 0); + assert_eq!(decoded.c2pa_ignored, 0); let reserved_last = PngEncoder::new() .with_c2pa(&store) @@ -333,10 +335,10 @@ fn the_first_store_wins_and_a_second_is_counted_not_merged() { ]); let meta = gamut_png::metadata(&png).expect("metadata"); assert_eq!(meta.c2pa.as_deref(), Some(&b"first"[..])); - assert_eq!(meta.c2pa_duplicates, 1); + assert_eq!(meta.c2pa_ignored, 1); let decoded = PngDecoder::new().decode(&png).expect("decode"); assert_eq!(decoded.c2pa.as_deref(), Some(&b"first"[..])); - assert_eq!(decoded.c2pa_duplicates, 1); + assert_eq!(decoded.c2pa_ignored, 1); let report = deconstruct(&png).expect("deconstruct"); let span = report.c2pa().expect("span"); @@ -345,6 +347,132 @@ fn the_first_store_wins_and_a_second_is_counted_not_merged() { assert_eq!(report.chunk(b"caBX").expect("stats").count, 2); } +/// A `caBX` positioned **after** `IDAT` is never the store: §A.3.2 puts the store before `IDAT` +/// and calls data after it bad-form, so accepting one would let anybody append a store to a +/// finished file and have it read back as that file's provenance. It is ignored as the store but +/// not hidden — the decode counts it, and the byte accounting still shows the chunk. +#[test] +fn a_cabx_after_idat_is_never_the_store_but_is_still_visible() { + let appended = png_from_chunks(&[ + chunk(b"IHDR", &ihdr_payload(3, 2, 8, 2, 0)), + chunk(b"IDAT", &zlib(&[0u8; 20])), + chunk(b"caBX", b"appended store"), + chunk(b"IEND", &[]), + ]); + let meta = gamut_png::metadata(&appended).expect("metadata"); + assert_eq!(meta.c2pa, None, "an appended chunk is not the file's store"); + assert_eq!(meta.c2pa_ignored, 1, "but it is counted, not hidden"); + let decoded = PngDecoder::new().decode(&appended).expect("decode"); + assert_eq!(decoded.c2pa, None); + assert_eq!(decoded.c2pa_ignored, 1); + + // The byte accounting still sees the chunk; it just does not call it the store. + let report = deconstruct(&appended).expect("deconstruct"); + assert_eq!(report.c2pa(), None, "no store span for an appended chunk"); + assert_eq!(report.chunk(b"caBX").expect("stats").count, 1); + + // A real store before IDAT is unaffected by a second one appended after it: the first wins + // and the appended one is counted, so the two rules compose. + let both = png_from_chunks(&[ + chunk(b"IHDR", &ihdr_payload(3, 2, 8, 2, 0)), + chunk(b"caBX", b"real store"), + chunk(b"IDAT", &zlib(&[0u8; 20])), + chunk(b"caBX", b"appended"), + chunk(b"IEND", &[]), + ]); + let meta = gamut_png::metadata(&both).expect("metadata"); + assert_eq!(meta.c2pa.as_deref(), Some(&b"real store"[..])); + assert_eq!(meta.c2pa_ignored, 1); + let span = deconstruct(&both).expect("deconstruct").c2pa().expect("span"); + assert_eq!(&both[span.payload], b"real store"); +} + +/// The two ignore-reasons add up rather than replacing one another: a duplicate before `IDAT` +/// and two chunks after it are three ignored chunks, counted the same way by both entry points. +#[test] +fn ignored_stores_before_and_after_idat_are_counted_together() { + let png = png_from_chunks(&[ + chunk(b"IHDR", &ihdr_payload(3, 2, 8, 2, 0)), + chunk(b"caBX", b"the store"), + chunk(b"caBX", b"duplicate"), + chunk(b"IDAT", &zlib(&[0u8; 20])), + chunk(b"caBX", b"appended one"), + chunk(b"caBX", b"appended two"), + chunk(b"IEND", &[]), + ]); + let meta = gamut_png::metadata(&png).expect("metadata"); + assert_eq!(meta.c2pa.as_deref(), Some(&b"the store"[..])); + assert_eq!(meta.c2pa_ignored, 3, "one duplicate plus two appended"); + let decoded = PngDecoder::new().decode(&png).expect("decode"); + assert_eq!(decoded.c2pa_ignored, 3, "decode agrees with metadata"); +} + +/// The reserve-then-fill flow end to end, the way a signer runs it: reserve, take the span, +/// fill in place, read the store back. The filled file is byte-identical to one encoded with +/// the store set from the start, which is what makes the two routes interchangeable — and the +/// signer's hash, taken over the reserved file with the span excluded, still describes it. +#[test] +fn a_reserved_store_is_filled_in_place_to_the_same_bytes_as_a_second_encode() { + let (pixels, dims) = rgb_source(); + let image = ImageRef::::new(&pixels, dims).expect("image"); + let finished = store(64); + + let (mut reserved, report) = everything_else() + .with_c2pa_reserved(64) + .encode_with_report(image) + .expect("encode"); + let span = report.c2pa.expect("span"); + let outside_before: Vec = (0..reserved.len()) + .filter(|i| !span.chunk.contains(i)) + .map(|i| reserved[i]) + .collect(); + + fill_c2pa(&mut reserved, &span, &finished).expect("fill"); + + let reencoded = everything_else() + .with_c2pa(&finished) + .encode_to_vec(image) + .expect("encode"); + assert_eq!(reserved, reencoded, "filling in place lands on the encoder's own bytes"); + + let outside_after: Vec = (0..reserved.len()) + .filter(|i| !span.chunk.contains(i)) + .map(|i| reserved[i]) + .collect(); + assert_eq!(outside_before, outside_after, "no byte outside the span moved"); + + // The filled store reads back through the ordinary decode path, so its CRC is right. + assert_eq!( + gamut_png::metadata(&reserved).expect("metadata").c2pa, + Some(finished) + ); + let typed: ImageBuf = PngDecoder::new().decode_image(&reserved).expect("decode"); + assert_eq!(typed.as_samples(), pixels, "the pixels are untouched"); +} + +/// The indexed route (`encode_indexed8` has no report of its own): take the span from the byte +/// accounting, fill in place, read it back. +#[test] +fn an_indexed_encode_reserves_and_fills_through_the_report() { + let palette = PngPalette::new(&[[1, 2, 3], [4, 5, 6]]).expect("palette"); + let indices = [0u8, 1, 1, 0, 1, 0]; + let image = + ImageRef::::new(&indices, Dimensions::new(3, 2).expect("valid")).expect("image"); + let mut png = Vec::new(); + PngEncoder::new() + .with_c2pa_reserved(24) + .encode_indexed8(image, &palette, &mut png) + .expect("encode"); + + let span = deconstruct(&png).expect("deconstruct").c2pa().expect("span"); + let finished = store(24); + fill_c2pa(&mut png, &span, &finished).expect("fill"); + assert_eq!( + gamut_png::metadata(&png).expect("metadata").c2pa, + Some(finished) + ); +} + /// A `caBX` whose CRC does not match is skipped on decode (§13.1) — it is not the store and /// it is not a duplicate either, since it never reaches the metadata pass — and the exclusion /// span names the CRC-valid store the decoder actually surfaces, not the damaged bytes before @@ -363,7 +491,7 @@ fn a_cabx_with_a_bad_crc_is_neither_the_store_nor_the_exclusion_span() { ]); let meta = gamut_png::metadata(&png).expect("metadata"); assert_eq!(meta.c2pa.as_deref(), Some(&b"valid"[..])); - assert_eq!(meta.c2pa_duplicates, 0); + assert_eq!(meta.c2pa_ignored, 0); let report = deconstruct(&png).expect("deconstruct"); let span = report.c2pa().expect("the valid store"); diff --git a/crates/gamut-png/tests/metadata.rs b/crates/gamut-png/tests/metadata.rs index c1b10acc..e63c8d94 100644 --- a/crates/gamut-png/tests/metadata.rs +++ b/crates/gamut-png/tests/metadata.rs @@ -103,7 +103,7 @@ fn metadata_agrees_with_decode_field_for_field() { assert_eq!(meta.icc_profile, decoded.icc_profile); assert_eq!(meta.xmp, decoded.xmp); assert_eq!(meta.c2pa, decoded.c2pa); - assert_eq!(meta.c2pa_duplicates, decoded.c2pa_duplicates); + assert_eq!(meta.c2pa_ignored, decoded.c2pa_ignored); assert_eq!(meta.texts, decoded.texts); assert_eq!(meta.gamma, decoded.gamma); assert_eq!(meta.chromaticities, decoded.chromaticities); From a4f9e7f372ca09ad758b74723c3fe67afcea4db7 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 9 Sep 2026 14:27:38 -0400 Subject: [PATCH 70/94] refactor(png): name what a metadata walk returns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `walk_metadata_chunks` grew a second result — the count of `caBX` chunks found after IDAT — and the bare tuple tripped `clippy::type_complexity`. The alias carries the explanation the tuple could not: which chunks come back, and why the post-IDAT ones are a number rather than chunks. --- crates/gamut-png/src/decoder.rs | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/crates/gamut-png/src/decoder.rs b/crates/gamut-png/src/decoder.rs index 4441d261..8a595eb4 100644 --- a/crates/gamut-png/src/decoder.rs +++ b/crates/gamut-png/src/decoder.rs @@ -478,11 +478,7 @@ impl PngDecoder { /// ``` pub fn metadata(&self, data: &[u8]) -> Result { let (chunks, c2pa_after_idat) = walk_metadata_chunks(data)?; - Ok(collected( - &chunks, - self.max_metadata_bytes, - c2pa_after_idat, - )) + Ok(collected(&chunks, self.max_metadata_bytes, c2pa_after_idat)) } /// Runs the typed pipeline: parse (without metadata) → decode. @@ -552,6 +548,11 @@ impl PngDecoder { } } +/// What one metadata walk found: the chunks in a position where their type may appear, and how +/// many CRC-valid `caBX` chunks sat after `IDAT` — never the store (C2PA §A.3.2), so counted +/// rather than returned among the chunks. +type MetadataWalk<'a> = (Vec<([u8; 4], &'a [u8])>, usize); + /// Walks the chunk stream collecting the CRC-valid ancillary chunks, for [`decoded::collect`] to /// classify — the same handoff [`PngDecoder::parse_stream`] makes, so the two entry points cannot /// disagree about which chunks carry metadata. @@ -560,10 +561,11 @@ impl PngDecoder { /// `Vec` and then requires at least one, neither of which a metadata read should do. Here IDAT /// (and PLTE) is skipped by length, so the pixel data is never touched or copied. /// -/// Returns the chunks together with the number of CRC-valid `caBX` chunks seen *after* `IDAT`, +/// Returns a [`MetadataWalk`]: the chunks together with the number of CRC-valid `caBX` chunks +/// seen *after* `IDAT`, /// which are never the store (§A.3.2) and so are counted rather than returned — the same split /// [`PngDecoder::parse_stream`] makes, so the two entry points agree on what the store is. -fn walk_metadata_chunks(data: &[u8]) -> Result<(Vec<([u8; 4], &[u8])>, usize)> { +fn walk_metadata_chunks(data: &[u8]) -> Result> { let mut reader = ChunkReader::new(data)?; let first = reader .next_chunk()? @@ -650,11 +652,7 @@ fn walk_metadata_chunks(data: &[u8]) -> Result<(Vec<([u8; 4], &[u8])>, usize)> { /// /// The one place that addition happens, so `decode` and both `metadata` entry points cannot come /// to report different counts for the same file. -fn collected( - chunks: &[([u8; 4], &[u8])], - budget: usize, - c2pa_after_idat: usize, -) -> PngMetadata { +fn collected(chunks: &[([u8; 4], &[u8])], budget: usize, c2pa_after_idat: usize) -> PngMetadata { let mut meta = decoded::collect(chunks, budget); meta.c2pa_ignored += c2pa_after_idat; meta From 35957e05fd59b3033ecacb569dd246b79143a491 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 9 Sep 2026 14:27:50 -0400 Subject: [PATCH 71/94] docs(png): separate C2PA carriage from what a decode admits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The span docs claimed the report names the chunk the decode surfaces as its payload. That is false under a budget: `PngReport::c2pa` has no byte budget, so a store past `with_max_metadata_bytes` is still spanned while `decode().c2pa` is `None` — a caller gating on the report could get a `None` it had been told could not happen. The same block claimed the ignored count agrees with `chunk(b"caBX")`, which it deliberately does not, since that counts CRC-invalid and post-IDAT chunks too. Both now say what is true: the report answers *where the bytes are* and has no budget, the decode answers *what was admitted*, and each count answers its own question. Exclude the span from a hash; read the payload from the decode. Also states two things the code already did but the docs did not. The reserve-then-fill flow's step 3 is `fill_c2pa`, with re-encoding named as the costlier alternative rather than the route. And "last chunk before IDAT" is this writer's guarantee about files it produces, not a property of the format: PNG §14.3.2 says ordering relative to other *ancillary* chunks is never assumable and an editor may insert one after ours, so readers assume only "before IDAT" — which is exactly what they do. --- crates/gamut-png/README.md | 7 ++-- crates/gamut-png/STATUS.md | 63 +++++++++++++++++++++---------- crates/gamut-png/src/ancillary.rs | 14 ++++++- crates/gamut-png/src/chunk.rs | 35 ++++++++++++++--- crates/gamut-png/src/encoder.rs | 33 +++++++++++----- crates/gamut-png/tests/c2pa.rs | 20 ++++++++-- 6 files changed, 129 insertions(+), 43 deletions(-) diff --git a/crates/gamut-png/README.md b/crates/gamut-png/README.md index 22c6bb2b..b62432c3 100644 --- a/crates/gamut-png/README.md +++ b/crates/gamut-png/README.md @@ -21,9 +21,10 @@ Graphics, W3C 3rd edition) images: decode-side inflate. - **C2PA carriage** (issue #440). The manifest store is located, bounded, carried and reserved — never parsed or judged. `with_c2pa` / `with_c2pa_reserved` put it as the last chunk before - `IDAT`, and `encode_with_report` / `PngReport::c2pa` name the chunk's whole span (length, type, - payload, CRC) for the `c2pa.hash.data` exclusion, so a reservation is filled by a second encode - that changes no byte outside it. Validation is `c2pa-rs`'s. + `IDAT`; `encode_with_report` / `PngReport::c2pa` name the chunk's whole span (length, type, + payload, CRC) for the `c2pa.hash.data` exclusion; and `fill_c2pa` writes the signed store into + that span in place, changing no byte outside it. On read the store is the first CRC-valid `caBX` + before `IDAT` — an appended one is counted, never surfaced. Validation is `c2pa-rs`'s. - **Memory-safe.** 100% safe Rust (`#![deny(unsafe_code)]`). ## Usage diff --git a/crates/gamut-png/STATUS.md b/crates/gamut-png/STATUS.md index 4d7b0d64..649e9a5c 100644 --- a/crates/gamut-png/STATUS.md +++ b/crates/gamut-png/STATUS.md @@ -39,7 +39,7 @@ opts into narrowing. That is distinct from the encoder's *lossless* auto-reduce | 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 | -| C1 | C2PA 2.4 §A.3.2, §18.5.4 | **C2PA carriage** (#440): the `caBX` manifest store — raw decode surface (`c2pa`; first wins, duplicates counted, under the metadata budget); `with_c2pa` / `with_c2pa_reserved` as the last chunk before `IDAT`; the whole-chunk exclusion span from `encode_with_report` and `PngReport::c2pa` (see [C2PA](#c2pa-manifest-store-issue-440)) | ✅ done | +| C1 | C2PA 2.4 §A.3.2, §18.5.4 | **C2PA carriage** (#440): the `caBX` manifest store — raw decode surface (`c2pa`; first CRC-valid chunk before `IDAT` wins, ignored ones counted, under the metadata budget); `with_c2pa` / `with_c2pa_reserved` as the last chunk before `IDAT`; the whole-chunk exclusion span from `encode_with_report` and `PngReport::c2pa`, filled in place by `fill_c2pa` (see [C2PA](#c2pa-manifest-store-issue-440)) | ✅ done | ## Decoder phases (issue #249) @@ -68,14 +68,21 @@ unsafe-to-copy chunk, which is the container enforcing the same no-copy-forward `gamut-metadata`'s `C2paPolicy` states for the facade: a store is bound to the bytes it was signed over, so one copied forward into a rewritten file is invalid by construction. -**Decode.** `DecodedPng::c2pa` / `PngMetadata::c2pa` carry the first `caBX` verbatim, ready for -`MetadataBlock::C2pa`. Exactly one store per file: a later `caBX` is counted in `c2pa_duplicates` -(saturating at 255), never concatenated — PNG has no multi-chunk store, unlike JPEG's APP11 run. -The store is attacker-sized like every ancillary payload, so its bytes are charged to the one -cumulative `with_max_metadata_bytes` budget; a store past the remainder is skipped, not an error, -and — skipped — is still the file's first store, so a smaller one after it is a duplicate rather -than a substitute. A `caBX` whose CRC does not match is skipped (§13.1) on both the decode and the -byte-accounting side, so the two agree on which chunk is the store. +**What counts as the store.** One rule, in every reader: the **first CRC-valid `caBX` before the +first `IDAT`**. First, because a file carries exactly one store (§A.3.2) — PNG has no multi-chunk +store, unlike JPEG's APP11 run. CRC-valid, because §13.1 makes a mismatch skippable and the decode +skips it. Before `IDAT`, because §A.3.2 puts it there and calls data after it bad-form: a `caBX` +appended to a finished file is not that file's provenance, and accepting one would let an appender +give a store to a file that carries none. + +**Decode.** `DecodedPng::c2pa` / `PngMetadata::c2pa` carry that chunk verbatim, ready for +`MetadataBlock::C2pa`. Every CRC-valid `caBX` that is *not* the store — a later one, or any after +`IDAT` — is counted in `c2pa_ignored` (a `usize`; the file's real number, not a saturated +ceiling), never concatenated. `c2pa == None` with a non-zero count is exactly the appended-store +shape. The store is attacker-sized like every ancillary payload, so its bytes are charged to the +one cumulative `with_max_metadata_bytes` budget; a store past the remainder is skipped, not an +error, and — skipped — is still the file's first store, so a smaller one after it is ignored +rather than substituted. **Encode.** `with_c2pa(store)` embeds a store computed for this file; `with_c2pa_reserved(len)` writes `len` zero bytes in its place. Either is emitted as the **last** chunk before the first @@ -86,12 +93,31 @@ reservation, hash with the chunk's span excluded, then encode again with the fin same length — the output is byte-reproducible, so the second file differs from the first only in the payload and the chunk CRC. `tests/c2pa.rs` pins that as an exact-byte diff. -**Exclusion span.** `encode_with_report` (for the file just written) and `PngReport::c2pa` (for -any file) name the chunk's **whole** span — length, type, payload and CRC — as `C2paSpan`, with -the payload bracketed inside it. §18.5.4 says the length and type go inside the exclusion; the CRC -must too, since it changes with the payload, and a `c2pa.hash.data` computed over any of them -breaks on the store's first write. The span is derived from the same chunk walk the byte -accounting uses, so it is always one of the report's claimed segments. +**Exclusion span, and filling it.** `encode_with_report` (for the file just written) and +`PngReport::c2pa` (for any file, including an indexed encode) name the chunk's **whole** span — +length, type, payload and CRC — as `C2paSpan`, with the payload bracketed inside it. §18.5.4 says +the length and type go inside the exclusion; the CRC must too, since it changes with the payload, +and a `c2pa.hash.data` computed over any of them breaks on the store's first write. The span is +derived from the same chunk walk the byte accounting uses, so it is always one of the report's +claimed segments. + +`fill_c2pa(&mut png, &span, store)` then writes the finished store into that span in place, +rewriting the payload and the chunk CRC and nothing else — O(store) rather than the O(encode) of a +second `with_c2pa` pass, and without tying the signature to the encoder reproducing its output. +Its arguments are validated first (span inside the image, framing a chunk, naming a `caBX`, store +exactly the reserved length), so a rejected call leaves the file untouched rather than half +filled. + +A span is **carriage**, not a decode result. The report has no byte budget, so a store past +`with_max_metadata_bytes` is still spanned here while `decode().c2pa` is `None`; likewise +`chunk(b"caBX").count` counts CRC-invalid and post-`IDAT` chunks that `c2pa_ignored` does not. +Each number answers its own question, and the docs say so rather than promising they agree. + +**Placement is ours, not the format's.** The store is written last before `IDAT` so its offset +depends only on what precedes it — the property the reserve-then-fill flow rests on. PNG §14.3.2 +warns that ordering relative to *other ancillary chunks* is never guaranteed and an editor may +insert one after ours, so "last" describes files as this encoder wrote them; readers assume only +"before `IDAT`". **Oracle.** libpng has no C2PA support and carries `caBX` as an unknown chunk — which is exactly the proof needed for framing: for the same payload it must produce the same length, type and CRC @@ -99,10 +125,9 @@ bytes as gamut, it must decode gamut's file pixel-exact with the chunk in place, the store from a libpng-written file. The behavioural oracle (`c2pa-rs`, against which a store's hash assertion can be checked over the excluded span) is issue #447. -**Not done, by design.** No in-place fill helper: a reservation is filled by a second encode, which -costs a second encode. No JUMBF parsing, not even of the outer box length. `gamut convert` does not -carry a store across a re-encode (that is the facade's `C2paPolicy` law, and the CLI's own path is -#448/#483). +**Not done, by design.** No JUMBF parsing, not even of the outer box length. No validation verdict +of any kind. `gamut convert` does not carry a store across a re-encode (that is the facade's +`C2paPolicy` law, and the CLI's own path is #448/#483). ## Efficiency (issue #224) diff --git a/crates/gamut-png/src/ancillary.rs b/crates/gamut-png/src/ancillary.rs index 7c697e8e..da7a0cd6 100644 --- a/crates/gamut-png/src/ancillary.rs +++ b/crates/gamut-png/src/ancillary.rs @@ -6,8 +6,18 @@ //! One chunk here is not PNG's own: the C2PA manifest store, `caBX` (C2PA 2.4 §A.3.2). It is //! emitted **last** of everything before `IDAT`, so that its offset depends only on the chunks //! that precede it and every byte after it is `IDAT` or `IEND` — which is what lets a reserved -//! store be filled in place by a second encode of equal length without moving a byte outside -//! the chunk. §A.3.2 asks only that it precede `IDAT`. +//! store be filled in place ([`crate::fill_c2pa`]) without moving a byte outside the chunk. +//! §A.3.2 asks only that it precede `IDAT`. +//! +//! "Last" is this writer's guarantee about the files it produces, **not** a property that +//! survives other tools. PNG §14.3.2 is explicit that an unsafe-to-copy chunk's ordering +//! requirements are relative to the *critical* chunks only, that "it is never valid to assume +//! that a specific ancillary chunk type occurs with any particular positioning relative to other +//! ancillary chunks", and that a PNG editor may insert another ancillary chunk after one an +//! application always writes last. So a reader must assume no more than "before `IDAT`" — which +//! is exactly what [`crate::PngReport::c2pa`] and the decoder assume — while a *reservation* +//! whose offsets a signer depends on holds only for a file that has not been edited since this +//! encoder wrote it. //! //! 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 diff --git a/crates/gamut-png/src/chunk.rs b/crates/gamut-png/src/chunk.rs index 75861bb8..b04d90a1 100644 --- a/crates/gamut-png/src/chunk.rs +++ b/crates/gamut-png/src/chunk.rs @@ -59,7 +59,13 @@ impl RawChunk<'_> { /// Where a C2PA manifest store sits in a PNG: the `caBX` chunk's whole span and, inside it, the /// store's own bytes. Reported by /// [`PngEncoder::encode_with_report`](crate::PngEncoder::encode_with_report) for a file just -/// written and by [`PngReport::c2pa`](crate::PngReport::c2pa) for any file. +/// written and by [`PngReport::c2pa`](crate::PngReport::c2pa) for any file, and consumed by +/// [`fill_c2pa`] to write the finished store into it. +/// +/// A span describes **carriage** — where the bytes sit — and says nothing about whether a decode +/// surfaced them: [`PngDecoder::with_max_metadata_bytes`](crate::PngDecoder::with_max_metadata_bytes) +/// can skip a store this span still names, since a byte accounting has no budget and does not +/// borrow another reader's. Exclude the span from a hash; read the payload from the decode. /// /// Non-exhaustive: a later revision may name a further range without a breaking change. #[derive(Debug, Clone, PartialEq, Eq)] @@ -456,7 +462,10 @@ mod tests { ); assert_eq!(short, png, "a rejected fill writes nothing"); let mut long = png.clone(); - assert!(fill_c2pa(&mut long, &span, b"abcde").is_err(), "one byte long"); + assert!( + fill_c2pa(&mut long, &span, b"abcde").is_err(), + "one byte long" + ); assert_eq!(long, png); // A span past the end of the buffer. @@ -471,13 +480,19 @@ mod tests { payload: span.payload.start + 1..span.payload.end, }; let error = fill_c2pa(&mut mine, &skewed, b"abc").expect_err("not framed"); - assert!(error.to_string().contains("does not frame a chunk"), "{error}"); + assert!( + error.to_string().contains("does not frame a chunk"), + "{error}" + ); assert_eq!(mine, png); // A well-framed span naming some other chunk: the IHDR right before it. let ihdr = C2paSpan::of(8..8 + 12 + 13); let error = fill_c2pa(&mut mine, &ihdr, &[0; 13]).expect_err("not a caBX"); - assert!(error.to_string().contains("does not name a caBX"), "{error}"); + assert!( + error.to_string().contains("does not name a caBX"), + "{error}" + ); assert_eq!(mine, png); } @@ -501,13 +516,21 @@ mod tests { write_chunk(&mut after_idat, *b"IDAT", b"zz"); write_chunk(&mut after_idat, CABX, b"appended"); write_chunk(&mut after_idat, *b"IEND", &[]); - assert_eq!(find_c2pa(&after_idat), None, "a caBX after IDAT is not the store"); + assert_eq!( + find_c2pa(&after_idat), + None, + "a caBX after IDAT is not the store" + ); let mut after_iend = SIGNATURE.to_vec(); write_chunk(&mut after_iend, *b"IHDR", &[0; 13]); write_chunk(&mut after_iend, *b"IEND", &[]); write_chunk(&mut after_iend, CABX, b"trailing"); - assert_eq!(find_c2pa(&after_iend), None, "a caBX after IEND is not the store"); + assert_eq!( + find_c2pa(&after_iend), + None, + "a caBX after IEND is not the store" + ); // ...while the same chunk one position earlier — before IDAT — is the store, so the // stop is what decides, not the payload. diff --git a/crates/gamut-png/src/encoder.rs b/crates/gamut-png/src/encoder.rs index 2b8353f5..b855978b 100644 --- a/crates/gamut-png/src/encoder.rs +++ b/crates/gamut-png/src/encoder.rs @@ -377,6 +377,10 @@ impl PngEncoder { /// into the same bytes — see there for the reserve-then-fill flow. The bytes are not parsed /// or validated: gamut carries the store, `c2pa-rs` judges it. /// + /// To put a finished store into a file that has **already** been encoded, prefer + /// [`fill_c2pa`](crate::fill_c2pa): it rewrites the reserved chunk in place, where this + /// setter re-runs the whole encode. + /// /// A store is bound to the bytes it was signed over, which is why no gamut re-encode helper — /// and never the `gamut-metadata` facade — hands one to this setter: a store copied forward /// into a rewritten file is invalid by construction, and `caBX` is *unsafe to copy* for the @@ -395,17 +399,26 @@ impl PngEncoder { /// The reserve-then-fill flow an external signer needs (C2PA 2.4 §18.5): /// /// 1. encode with the reservation, via [`encode_with_report`](Self::encode_with_report), which - /// names the chunk's span; + /// names the chunk's span (for an indexed image, `deconstruct(&png)?.c2pa()` names the same + /// span — see [`encode_indexed8`](Self::encode_indexed8)); /// 2. hash the output with that **whole** span excluded — length, type, payload and CRC /// (§18.5.4) — and have the signer build the store against it; - /// 3. encode again with [`with_c2pa`](Self::with_c2pa) and the finished store of the **same - /// length**. The encoder's output is byte-reproducible and the store is the last chunk - /// before `IDAT`, so the second file differs from the first only inside that span: the - /// payload and the chunk CRC. Every other byte, and every offset, is unchanged. + /// 3. write the finished store into the span with [`fill_c2pa`](crate::fill_c2pa). Only the + /// payload and the chunk CRC change, so every other byte — and every offset — is the one + /// the signer hashed. + /// + /// Re-encoding with [`with_c2pa`](Self::with_c2pa) and a store of the same length reaches the + /// same bytes, because the output is byte-reproducible and the store is the last chunk before + /// `IDAT`; it costs a second full encode and ties the signature to that reproducibility, which + /// is why the in-place fill is the documented step 3. /// /// The reservation is `len` bytes exactly — no slack is added — so ask for what the signer /// says it needs (`c2pa-rs` reports a `reserve_size`). /// + /// The offsets hold for the file as this encoder wrote it. A PNG editor may lawfully insert + /// another ancillary chunk after the store (PNG §14.3.2), so reserve, hash and fill without + /// passing the file through one. + /// /// The last of `with_c2pa` / `with_c2pa_reserved` wins; a file carries exactly one store. #[must_use] pub fn with_c2pa_reserved(mut self, len: usize) -> Self { @@ -418,10 +431,12 @@ impl PngEncoder { /// [`with_c2pa_reserved`](Self::with_c2pa_reserved). /// /// The report is read back from the bytes written — the same walk - /// [`PngReport::c2pa`](crate::PngReport::c2pa) performs — so it cannot disagree with what a - /// later [`deconstruct`](crate::deconstruct) of the same bytes reports, and an indexed image - /// encoded through [`encode_indexed8`](Self::encode_indexed8) gets the same answer from - /// `deconstruct(&png)?.c2pa()`. + /// [`PngReport::c2pa`](crate::PngReport::c2pa) performs, over the same rule (the first + /// CRC-valid `caBX` before the first `IDAT`) — so it cannot disagree with what a later + /// [`deconstruct`](crate::deconstruct) of the same bytes reports. There is deliberately no + /// indexed twin of this method: [`encode_indexed8`](Self::encode_indexed8) needs a palette and + /// does not fit this shape, and `deconstruct(&png)?.c2pa()` gives an indexed caller the same + /// span, which [`fill_c2pa`](crate::fill_c2pa) then fills. /// /// # Errors /// diff --git a/crates/gamut-png/tests/c2pa.rs b/crates/gamut-png/tests/c2pa.rs index 29c4c69f..b1229c9c 100644 --- a/crates/gamut-png/tests/c2pa.rs +++ b/crates/gamut-png/tests/c2pa.rs @@ -383,7 +383,10 @@ fn a_cabx_after_idat_is_never_the_store_but_is_still_visible() { let meta = gamut_png::metadata(&both).expect("metadata"); assert_eq!(meta.c2pa.as_deref(), Some(&b"real store"[..])); assert_eq!(meta.c2pa_ignored, 1); - let span = deconstruct(&both).expect("deconstruct").c2pa().expect("span"); + let span = deconstruct(&both) + .expect("deconstruct") + .c2pa() + .expect("span"); assert_eq!(&both[span.payload], b"real store"); } @@ -433,13 +436,19 @@ fn a_reserved_store_is_filled_in_place_to_the_same_bytes_as_a_second_encode() { .with_c2pa(&finished) .encode_to_vec(image) .expect("encode"); - assert_eq!(reserved, reencoded, "filling in place lands on the encoder's own bytes"); + assert_eq!( + reserved, reencoded, + "filling in place lands on the encoder's own bytes" + ); let outside_after: Vec = (0..reserved.len()) .filter(|i| !span.chunk.contains(i)) .map(|i| reserved[i]) .collect(); - assert_eq!(outside_before, outside_after, "no byte outside the span moved"); + assert_eq!( + outside_before, outside_after, + "no byte outside the span moved" + ); // The filled store reads back through the ordinary decode path, so its CRC is right. assert_eq!( @@ -464,7 +473,10 @@ fn an_indexed_encode_reserves_and_fills_through_the_report() { .encode_indexed8(image, &palette, &mut png) .expect("encode"); - let span = deconstruct(&png).expect("deconstruct").c2pa().expect("span"); + let span = deconstruct(&png) + .expect("deconstruct") + .c2pa() + .expect("span"); let finished = store(24); fill_c2pa(&mut png, &span, &finished).expect("fill"); assert_eq!( From 60aec6421af4441972dc48725422b41d347fd5df Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 9 Sep 2026 14:47:06 -0400 Subject: [PATCH 72/94] test(png): pin the exact fit in the C2PA fill's bounds check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mutation gate caught the gap: `span.chunk.end > png.len()` could be weakened to `>=` and every test still passed, because each fixture put an IDAT and an IEND after the store, so the chunk never ended at the buffer's end. `fill_c2pa` takes a `&mut [u8]`, so the exact fit is a legitimate call — the prefix of a file up to the end of its store, or a file whose store is its last chunk — and under `>=` every such call is refused. --- crates/gamut-png/src/chunk.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/gamut-png/src/chunk.rs b/crates/gamut-png/src/chunk.rs index b04d90a1..5452d984 100644 --- a/crates/gamut-png/src/chunk.rs +++ b/crates/gamut-png/src/chunk.rs @@ -496,6 +496,18 @@ mod tests { assert_eq!(mine, png); } + /// The bounds check admits the exact fit: a buffer that ends exactly where the chunk does is + /// in range, not past it. `fill_c2pa` takes a `&mut [u8]`, so a caller may legitimately hand + /// it the prefix of a file up to the end of the store — and a file whose store happens to be + /// its last chunk is the same shape. Off by one here and every such call is refused. + #[test] + fn a_buffer_ending_exactly_where_the_chunk_does_is_in_range() { + let (mut png, span) = reserved(4); + let end = span.chunk.end; + fill_c2pa(&mut png[..end], &span, b"abcd").expect("the chunk ends at the buffer's end"); + assert_eq!(&png[span.payload], b"abcd"); + } + /// A zero-length reservation is a legal chunk, and filling it with nothing is a no-op that /// still verifies — the boundary where payload start and end coincide. #[test] From eca33fa5abda4e0749c697cb3e2e405834cb2128 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 9 Sep 2026 18:37:33 -0400 Subject: [PATCH 73/94] fix(png): reject a C2PA span the chunk's own length contradicts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `fill_c2pa` validated the span against itself and against the buffer's bounds, then read exactly four bytes of `png` — the chunk type — and wrote. It never compared the span with the length the chunk itself declares, so a span naming a longer chunk than the one actually there passed every guard: the payload and the recomputed CRC went over whatever followed, in practice the IDAT, and the call returned `Ok(())`. The mirror case, a span shorter than the chunk declares, planted the CRC inside the real payload, leaving a chunk `find_c2pa` then skips — the store silently not carried. Reaching it needs only a mismatched span, which the API allows by construction: `C2paSpan`'s fields are `pub`, `#[non_exhaustive]` blocks literal construction but not field assignment, and both hand-out points return owned values. Taking a span from one file and filling another is supported on purpose — the exact-fit case does it — which is precisely why the declared length has to be checked rather than assumed. No panic and no memory-safety issue: every write stayed inside the slice. But the documented contract says a span whose bytes are not a `caBX` chunk is rejected, and these bytes are not one. --- crates/gamut-png/src/chunk.rs | 73 +++++++++++++++++++++++++++++++++-- 1 file changed, 69 insertions(+), 4 deletions(-) diff --git a/crates/gamut-png/src/chunk.rs b/crates/gamut-png/src/chunk.rs index 5452d984..b4be1659 100644 --- a/crates/gamut-png/src/chunk.rs +++ b/crates/gamut-png/src/chunk.rs @@ -234,10 +234,15 @@ impl<'a> ChunkReader<'a> { /// /// Returns [`Error::InvalidInput`] and leaves `png` **unmodified** if `span` runs past the end of /// `png`, if it does not frame a chunk (its payload must be `chunk.start + 8 .. chunk.end - 4`), -/// if the bytes it names are not a `caBX` chunk, or if `store` is not exactly the reserved -/// length. Every check runs before the first byte is written, so a rejected call cannot leave a -/// half-filled chunk behind — and a store of the wrong length is rejected rather than resized, -/// because resizing would move every byte after the chunk and invalidate the signer's hash. +/// if the bytes it names are not a `caBX` chunk, if the span's payload length disagrees with the +/// length the chunk itself declares, or if `store` is not exactly the reserved length. Every check +/// runs before the first byte is written, so a rejected call cannot leave a half-filled chunk +/// behind — and a store of the wrong length is rejected rather than resized, because resizing +/// would move every byte after the chunk and invalidate the signer's hash. +/// +/// `png` need not be the buffer the span came from — filling a copy, or a buffer that ends where +/// the chunk does, is supported — which is exactly why the chunk's declared length is checked +/// against the span rather than assumed to match it. /// /// # Example /// @@ -283,6 +288,22 @@ pub fn fill_c2pa(png: &mut [u8], span: &C2paSpan, store: &[u8]) -> Result<()> { if png[span.chunk.start + 4..span.payload.start] != CABX { return Err(invalid("PNG: the C2PA span does not name a caBX chunk")); } + // The span must agree with the chunk's *own* length field, not merely with itself. Until this + // check the only bytes of `png` read were the four type bytes, so a span taken from one file + // and applied to another — which the `&mut [u8]` signature deliberately allows — could name a + // longer chunk than the one that is there and write the payload and CRC over whatever follows + // it, most likely IDAT, and report success. + let declared = u32::from_be_bytes([ + png[span.chunk.start], + png[span.chunk.start + 1], + png[span.chunk.start + 2], + png[span.chunk.start + 3], + ]); + if u64::try_from(span.payload.len()) != Ok(u64::from(declared)) { + return Err(invalid( + "PNG: the C2PA span disagrees with the chunk's declared length", + )); + } if store.len() != span.payload.len() { return Err(invalid("PNG: the C2PA store is not the reserved length")); } @@ -496,6 +517,50 @@ mod tests { assert_eq!(mine, png); } + /// A span must agree with the chunk's own length field, in both directions. `png` need not + /// be the buffer the span was taken from, so a span from a file whose store is long, applied + /// to a file whose store is short, would otherwise write the payload and a CRC straight over + /// the bytes that follow — here the `IDAT` — and report success. + #[test] + fn filling_rejects_a_span_the_chunks_declared_length_contradicts() { + // A caBX declaring 4 payload bytes, followed by a long IDAT. + let mut png = SIGNATURE.to_vec(); + write_chunk(&mut png, *b"IHDR", &[0; 13]); + let chunk_start = png.len(); + write_chunk(&mut png, CABX, &[0; 4]); + write_chunk(&mut png, *b"IDAT", &[0xEE; 40]); + write_chunk(&mut png, *b"IEND", &[]); + let untouched = png.clone(); + + // A span claiming a 40-byte payload at the same offset: every earlier guard passes — it + // is in bounds, it frames a chunk, and it starts at a real caBX. + let overlong = C2paSpan::of(chunk_start..chunk_start + 12 + 40); + let error = fill_c2pa(&mut png, &overlong, &[7; 40]).expect_err("longer than declared"); + assert!(error.to_string().contains("declared length"), "{error}"); + assert_eq!(png, untouched, "nothing was written over the IDAT"); + + // ...and the mirror: a span shorter than the chunk declares would plant the CRC inside + // the real payload, leaving a chunk no reader accepts. + let mut long_store = SIGNATURE.to_vec(); + write_chunk(&mut long_store, *b"IHDR", &[0; 13]); + let start = long_store.len(); + write_chunk(&mut long_store, CABX, &[0; 40]); + write_chunk(&mut long_store, *b"IEND", &[]); + let before = long_store.clone(); + let short = C2paSpan::of(start..start + 12 + 4); + let error = fill_c2pa(&mut long_store, &short, &[7; 4]).expect_err("shorter than declared"); + assert!(error.to_string().contains("declared length"), "{error}"); + assert_eq!(long_store, before); + + // The matching span still fills, so the check rejects disagreement, not every span. + let exact = C2paSpan::of(chunk_start..chunk_start + 12 + 4); + fill_c2pa(&mut png, &exact, b"good").expect("the declared length matches"); + assert_eq!( + find_c2pa(&png).map(|s| png[s.payload].to_vec()), + Some(b"good".to_vec()) + ); + } + /// The bounds check admits the exact fit: a buffer that ends exactly where the chunk does is /// in range, not past it. `fill_c2pa` takes a `&mut [u8]`, so a caller may legitimately hand /// it the prefix of a file up to the end of the store — and a file whose store happens to be From c49be4c42e29254fde22714ea5bb17f966d03c2e Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 9 Sep 2026 18:39:30 -0400 Subject: [PATCH 74/94] fix(png): count every ignored C2PA store, and say what is not counted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `c2pa_ignored`'s docs claimed that `c2pa == None` with a non-zero count meant someone had appended a store to a file carrying none. The unit test forty lines below asserted the counter-example: two `caBX` chunks both *before* IDAT, the first over the metadata budget, gives exactly that pair with nothing appended. The converse failed too — the canonical append is a `caBX` after IEND, which is a trailer neither walk reaches, so it counted zero. A caller gating injection detection on the field got both a false positive and a false negative on the one case the sentence named. The count now covers every CRC-valid `caBX` in the datastream that was not surfaced as the store, which adds the budget-skipped store-position chunk `collect` already saw and previously passed over. First-wins is unchanged: the first chunk still claims the store position whether or not it is admitted, so an oversized store cannot be substituted by a smaller one after it. The docs — the two struct fields, STATUS.md and the README — now state the three cases it counts, that it does not distinguish them, and that a chunk after IEND is outside the datastream and therefore outside the count, with `deconstruct`'s trailer segment named as where that shape is visible instead. Both claims are pinned by tests. --- crates/gamut-png/README.md | 3 +- crates/gamut-png/STATUS.md | 24 ++++++++------ crates/gamut-png/src/decoded.rs | 51 ++++++++++++++++++----------- crates/gamut-png/tests/c2pa.rs | 57 +++++++++++++++++++++++++++++++++ 4 files changed, 107 insertions(+), 28 deletions(-) diff --git a/crates/gamut-png/README.md b/crates/gamut-png/README.md index b62432c3..084b698c 100644 --- a/crates/gamut-png/README.md +++ b/crates/gamut-png/README.md @@ -24,7 +24,8 @@ Graphics, W3C 3rd edition) images: `IDAT`; `encode_with_report` / `PngReport::c2pa` name the chunk's whole span (length, type, payload, CRC) for the `c2pa.hash.data` exclusion; and `fill_c2pa` writes the signed store into that span in place, changing no byte outside it. On read the store is the first CRC-valid `caBX` - before `IDAT` — an appended one is counted, never surfaced. Validation is `c2pa-rs`'s. + before `IDAT`; every other one in the datastream is counted in `c2pa_ignored`, never surfaced. + Validation is `c2pa-rs`'s. - **Memory-safe.** 100% safe Rust (`#![deny(unsafe_code)]`). ## Usage diff --git a/crates/gamut-png/STATUS.md b/crates/gamut-png/STATUS.md index 649e9a5c..260f098a 100644 --- a/crates/gamut-png/STATUS.md +++ b/crates/gamut-png/STATUS.md @@ -76,13 +76,18 @@ appended to a finished file is not that file's provenance, and accepting one wou give a store to a file that carries none. **Decode.** `DecodedPng::c2pa` / `PngMetadata::c2pa` carry that chunk verbatim, ready for -`MetadataBlock::C2pa`. Every CRC-valid `caBX` that is *not* the store — a later one, or any after -`IDAT` — is counted in `c2pa_ignored` (a `usize`; the file's real number, not a saturated -ceiling), never concatenated. `c2pa == None` with a non-zero count is exactly the appended-store -shape. The store is attacker-sized like every ancillary payload, so its bytes are charged to the -one cumulative `with_max_metadata_bytes` budget; a store past the remainder is skipped, not an -error, and — skipped — is still the file's first store, so a smaller one after it is ignored -rather than substituted. +`MetadataBlock::C2pa`. The store is attacker-sized like every ancillary payload, so its bytes are +charged to the one cumulative `with_max_metadata_bytes` budget; a store past the remainder is +skipped, not an error, and — skipped — is still the file's first store, so a smaller one after it +is ignored rather than substituted. + +`c2pa_ignored` counts every CRC-valid `caBX` **in the datastream** that was not surfaced as the +store (a `usize`: the file's real number, not a saturated ceiling). That is three cases — a chunk +later than the first, one positioned after `IDAT`, and the store-position chunk itself when it +busted the budget — and the count deliberately does not say which: `c2pa == None` with a non-zero +count is any of them, not evidence of an appended store. What it does **not** cover is a `caBX` +after `IEND`, which is a trailer rather than part of the datastream (§13.2) and which neither +metadata walk reaches; that shape is visible in `deconstruct`'s report, as a trailer segment. **Encode.** `with_c2pa(store)` embeds a store computed for this file; `with_c2pa_reserved(len)` writes `len` zero bytes in its place. Either is emitted as the **last** chunk before the first @@ -110,8 +115,9 @@ filled. A span is **carriage**, not a decode result. The report has no byte budget, so a store past `with_max_metadata_bytes` is still spanned here while `decode().c2pa` is `None`; likewise -`chunk(b"caBX").count` counts CRC-invalid and post-`IDAT` chunks that `c2pa_ignored` does not. -Each number answers its own question, and the docs say so rather than promising they agree. +`chunk(b"caBX").count` counts CRC-invalid chunks and chunks in the trailer, which `c2pa_ignored` +does not. Each number answers its own question, and the docs say so rather than promising they +agree. **Placement is ours, not the format's.** The store is written last before `IDAT` so its offset depends only on what precedes it — the property the reserve-then-fill flow rests on. PNG §14.3.2 diff --git a/crates/gamut-png/src/decoded.rs b/crates/gamut-png/src/decoded.rs index b3a593c5..0b41153a 100644 --- a/crates/gamut-png/src/decoded.rs +++ b/crates/gamut-png/src/decoded.rs @@ -150,15 +150,19 @@ pub struct DecodedPng { /// Feed as `MetadataBlock::C2pa`. The first CRC-valid `caBX` before the first `IDAT`, and /// only when it fits the metadata budget; see [`c2pa_ignored`](Self::c2pa_ignored). pub c2pa: Option>, - /// How many CRC-valid `caBX` chunks the file carries that were **not** surfaced as the - /// store: any after the first, and any positioned after `IDAT`. + /// How many CRC-valid `caBX` chunks **in the datastream** were not surfaced as the store: + /// a chunk later than the first, a chunk positioned after `IDAT`, and the store-position + /// chunk itself when it did not fit the metadata budget. /// /// A file carries exactly one manifest store — PNG has no multi-chunk store, unlike JPEG's - /// APP11 run — so a non-zero count marks a malformed file whose extra chunks were ignored - /// rather than concatenated. The post-`IDAT` case is worth its own attention: §A.3.2 places - /// the store before `IDAT` and calls data after it bad-form, so a `caBX` appended to a - /// finished file is never read as the store. A file whose `c2pa` is `None` while this is - /// non-zero is exactly that shape — someone appended a store to a file that carries none. + /// APP11 run — so a non-zero count marks a file whose extra chunks were ignored rather than + /// concatenated. It does **not** identify why: a store past the budget and a chunk appended + /// after `IDAT` both land here, and `c2pa == None` with a non-zero count is either. + /// + /// A `caBX` after `IEND` is **not** counted. Bytes after `IEND` are a trailer rather than + /// part of the datastream (§13.2), and neither metadata walk reads them. To see one — the + /// shape of a chunk appended to a finished file — use + /// [`deconstruct`](crate::deconstruct), whose report accounts the trailer as a segment. pub c2pa_ignored: usize, /// tEXt/zTXt/iTXt annotations in file order (the XMP packet is excluded). pub texts: Vec, @@ -222,15 +226,19 @@ pub struct PngMetadata { /// `caBX` before the first `IDAT`, and only when it fits the metadata budget; see /// [`c2pa_ignored`](Self::c2pa_ignored). pub c2pa: Option>, - /// How many CRC-valid `caBX` chunks the file carries that were **not** surfaced as the - /// store: any after the first, and any positioned after `IDAT`. + /// How many CRC-valid `caBX` chunks **in the datastream** were not surfaced as the store: + /// a chunk later than the first, a chunk positioned after `IDAT`, and the store-position + /// chunk itself when it did not fit the metadata budget. /// /// A file carries exactly one manifest store — PNG has no multi-chunk store, unlike JPEG's - /// APP11 run — so a non-zero count marks a malformed file whose extra chunks were ignored - /// rather than concatenated. The post-`IDAT` case is worth its own attention: §A.3.2 places - /// the store before `IDAT` and calls data after it bad-form, so a `caBX` appended to a - /// finished file is never read as the store. A file whose `c2pa` is `None` while this is - /// non-zero is exactly that shape — someone appended a store to a file that carries none. + /// APP11 run — so a non-zero count marks a file whose extra chunks were ignored rather than + /// concatenated. It does **not** identify why: a store past the budget and a chunk appended + /// after `IDAT` both land here, and `c2pa == None` with a non-zero count is either. + /// + /// A `caBX` after `IEND` is **not** counted. Bytes after `IEND` are a trailer rather than + /// part of the datastream (§13.2), and neither metadata walk reads them. To see one — the + /// shape of a chunk appended to a finished file — use + /// [`deconstruct`](crate::deconstruct), whose report accounts the trailer as a segment. pub c2pa_ignored: usize, /// tEXt/zTXt/iTXt annotations in file order (the XMP packet is excluded). pub texts: Vec, @@ -247,8 +255,9 @@ pub struct PngMetadata { /// Parses the metadata-bearing ancillary chunks collected from the stream (in file order). /// Malformed payloads skip their chunk (§13.1); compressed payloads — and the uncompressed but /// attacker-sized `caBX` store — share `budget` bytes of output, and a payload that would bust -/// the remainder is skipped, not an error. Once-only chunks keep their first occurrence; a -/// second `caBX` is additionally counted, since exactly one store is the rule (C2PA §A.3.2). +/// the remainder is skipped, not an error. Once-only chunks keep their first occurrence; every +/// CRC-valid `caBX` this walk does not surface as the store — a later one, or the first when it +/// busts the budget — is counted, since exactly one store is the rule (C2PA §A.3.2). /// /// `chunks` holds only chunks in a position where a store may appear: the caller's walk drops a /// `caBX` after `IDAT` before it gets here and counts it into @@ -265,12 +274,18 @@ pub(crate) fn collect(chunks: &[([u8; 4], &[u8])], budget: usize) -> PngMetadata b"eXIf" if meta.exif.is_none() => meta.exif = Some(data.to_vec()), _ if chunk_type == CABX => { if seen_c2pa { + // A second store-position chunk: never the store, whatever became of the + // first, so an oversized store cannot be substituted by a smaller one. meta.c2pa_ignored += 1; } else { seen_c2pa = true; if data.len() <= budget { budget -= data.len(); meta.c2pa = Some(data.to_vec()); + } else { + // In store position but not admitted: counted, like every other + // CRC-valid `caBX` this walk declines to surface. + meta.c2pa_ignored += 1; } } } @@ -625,8 +640,8 @@ mod tests { let busts = collect(&[(CABX, &store), (CABX, b"tiny")], 9); assert_eq!(busts.c2pa, None, "one byte over the budget is skipped"); assert_eq!( - busts.c2pa_ignored, 1, - "the skipped store is still the first; the next is a duplicate, not a substitute" + busts.c2pa_ignored, 2, + "both are ignored: the store busted the budget, and the next is not a substitute" ); } diff --git a/crates/gamut-png/tests/c2pa.rs b/crates/gamut-png/tests/c2pa.rs index b1229c9c..fc58e84f 100644 --- a/crates/gamut-png/tests/c2pa.rs +++ b/crates/gamut-png/tests/c2pa.rs @@ -485,6 +485,63 @@ fn an_indexed_encode_reserves_and_fills_through_the_report() { ); } +/// The budget-skipped store is counted like every other chunk the walk declines to surface, so +/// the count means "CRC-valid `caBX` chunks in the datastream that are not the store" and not +/// "chunks somebody appended". Both entry points agree. +#[test] +fn a_store_past_the_budget_is_counted_among_the_ignored() { + let (pixels, dims) = rgb_source(); + let image = ImageRef::::new(&pixels, dims).expect("image"); + let png = PngEncoder::new() + .with_c2pa(&store(1000)) + .encode_to_vec(image) + .expect("encode"); + + let generous = PngDecoder::new().with_max_metadata_bytes(1000); + let meta = generous.metadata(&png).expect("metadata"); + assert!(meta.c2pa.is_some()); + assert_eq!(meta.c2pa_ignored, 0, "an admitted store is not ignored"); + + let tight = PngDecoder::new().with_max_metadata_bytes(999); + let meta = tight.metadata(&png).expect("metadata"); + assert_eq!(meta.c2pa, None); + assert_eq!( + meta.c2pa_ignored, 1, + "the store the budget skipped is counted" + ); + let decoded = tight.decode(&png).expect("decode"); + assert_eq!(decoded.c2pa_ignored, 1, "decode agrees with metadata"); +} + +/// What the count deliberately does **not** see: a `caBX` after `IEND` is a trailer, not part of +/// the datastream (§13.2), so neither metadata walk reaches it and it is counted zero. The byte +/// accounting is where that shape shows up, as a trailer segment — which is what the field's +/// docs point a caller at. +#[test] +fn a_cabx_after_iend_is_a_trailer_the_report_sees_and_the_count_does_not() { + let mut png = png_from_chunks(&[ + chunk(b"IHDR", &ihdr_payload(3, 2, 8, 2, 0)), + chunk(b"IDAT", &zlib(&[0u8; 20])), + chunk(b"IEND", &[]), + ]); + let datastream_len = png.len(); + png.extend_from_slice(&chunk(b"caBX", b"appended after IEND")); + + let meta = gamut_png::metadata(&png).expect("metadata"); + assert_eq!(meta.c2pa, None); + assert_eq!(meta.c2pa_ignored, 0, "a trailer is not in the datastream"); + + let report = deconstruct(&png).expect("deconstruct"); + assert_eq!(report.c2pa(), None, "a trailer is not the store either"); + let trailer = report + .segments + .iter() + .find(|segment| segment.kind == SegmentKind::Trailer) + .expect("the appended bytes are accounted as a trailer"); + assert_eq!(trailer.range, datastream_len..png.len()); + assert!(report.is_fully_classified()); +} + /// A `caBX` whose CRC does not match is skipped on decode (§13.1) — it is not the store and /// it is not a duplicate either, since it never reaches the metadata pass — and the exclusion /// span names the CRC-valid store the decoder actually surfaces, not the damaged bytes before From e1dddb7660f9206e3ea9b6f46bae2cea0c4bfc88 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 9 Sep 2026 19:05:06 -0400 Subject: [PATCH 75/94] refactor(png): read the C2PA chunk header as bytes, not offsets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The declared-length check read the length field through four indexed reads, `start`, `start + 1`, `start + 2`, `start + 3`. The mutation gate found the first of those offsets unkillable: for any payload under 64 KiB the top two length bytes are both zero, so reading one in place of the other changes nothing a fixture of that size can observe. The offsets are gone rather than papered over with a 64 KiB fixture. The eight header bytes are taken as one borrow and split where §5.3 splits them, and the length field is compared as bytes against the span's own payload length in network order — so there is no offset arithmetic left to get wrong, and a wrong split fails the type check that follows. The test gains the case the byte comparison earns: a 300-byte store, where a span agreeing only in the low byte (44) is rejected, which no single-byte length could have shown. --- crates/gamut-png/src/chunk.rs | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/crates/gamut-png/src/chunk.rs b/crates/gamut-png/src/chunk.rs index b4be1659..72378efc 100644 --- a/crates/gamut-png/src/chunk.rs +++ b/crates/gamut-png/src/chunk.rs @@ -285,7 +285,11 @@ pub fn fill_c2pa(png: &mut [u8], span: &C2paSpan, store: &[u8]) -> Result<()> { if !frames { return Err(invalid("PNG: the C2PA span does not frame a chunk")); } - if png[span.chunk.start + 4..span.payload.start] != CABX { + // The chunk's eight header bytes, split where §5.3 splits them. Taken as one borrow rather + // than by indexed reads so that no byte offset is computed here twice: `frames` above already + // established that these eight bytes are inside the buffer. + let (declared, kind) = png[span.chunk.start..span.payload.start].split_at(4); + if kind != CABX { return Err(invalid("PNG: the C2PA span does not name a caBX chunk")); } // The span must agree with the chunk's *own* length field, not merely with itself. Until this @@ -293,13 +297,11 @@ pub fn fill_c2pa(png: &mut [u8], span: &C2paSpan, store: &[u8]) -> Result<()> { // and applied to another — which the `&mut [u8]` signature deliberately allows — could name a // longer chunk than the one that is there and write the payload and CRC over whatever follows // it, most likely IDAT, and report success. - let declared = u32::from_be_bytes([ - png[span.chunk.start], - png[span.chunk.start + 1], - png[span.chunk.start + 2], - png[span.chunk.start + 3], - ]); - if u64::try_from(span.payload.len()) != Ok(u64::from(declared)) { + let agrees = matches!( + u32::try_from(span.payload.len()), + Ok(payload_len) if declared == payload_len.to_be_bytes() + ); + if !agrees { return Err(invalid( "PNG: the C2PA span disagrees with the chunk's declared length", )); @@ -559,6 +561,20 @@ mod tests { find_c2pa(&png).map(|s| png[s.payload].to_vec()), Some(b"good".to_vec()) ); + + // A length that does not fit one byte: every byte of the field is compared, so a span + // agreeing only in the low byte (300 vs 44) is rejected, and the true span fills. + let mut wide = SIGNATURE.to_vec(); + write_chunk(&mut wide, *b"IHDR", &[0; 13]); + let wide_start = wide.len(); + write_chunk(&mut wide, CABX, &[0; 300]); + write_chunk(&mut wide, *b"IEND", &[]); + let low_byte_only = C2paSpan::of(wide_start..wide_start + 12 + 44); + let error = fill_c2pa(&mut wide, &low_byte_only, &[7; 44]).expect_err("300 is not 44"); + assert!(error.to_string().contains("declared length"), "{error}"); + let whole = C2paSpan::of(wide_start..wide_start + 12 + 300); + fill_c2pa(&mut wide, &whole, &[9; 300]).expect("the declared length matches"); + assert_eq!(find_c2pa(&wide).map(|s| wide[s.payload].len()), Some(300)); } /// The bounds check admits the exact fit: a buffer that ends exactly where the chunk does is From 0a7e66517f38994f4e6cb4abe018b9ef8a91f3cb Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 00:36:01 -0400 Subject: [PATCH 76/94] feat(png): preserve metadata across a re-encode, and refuse the chunk pairs the spec forbids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PngEncoder::with_metadata` / `with_metadata_from` carry a decoded file's eXIf, iCCP, XMP, text and colour chunks into the encoder that rewrites its pixels, so a re-encode no longer drops every one of them. Two defects the spec settles are fixed on the way: * sRGB beside iCCP. PNG 3rd ed. §5.6 Table 5 states the constraint on both rows, and §11.3.2.5 repeats it: the two should not appear together. Both were written whenever both were set. The encode is now refused with `InvalidInput`, and `with_metadata` resolves the pair by §4.3 Table 1's colour-chunk priority (iCCP 2 outranks sRGB 3) so a file carrying both is still re-encodable. * tEXt/zTXt carried UTF-8. §11.3.3.2 interprets a tEXt text string as Latin-1 and §11.3.3.3 says an inflated zTXt is identical to it, while §11.3.3.1 restricts every keyword to Latin-1. Pushing a Rust `String`'s bytes stored mojibake for every code point above U+007F. Text is now converted once, at the setter, and a non-Latin-1 text is promoted to iTXt as §11.3.3.2 directs; a keyword no chunk can carry refuses the encode. Adds `with_cicp` (§11.3.2.6), without which preservation would silently drop the highest-precedence colour chunk a file carries. --- crates/gamut-png/src/ancillary.rs | 187 ++++++++++++++++++++++++---- crates/gamut-png/src/decoder.rs | 18 ++- crates/gamut-png/src/encoder.rs | 182 ++++++++++++++++++++++++++- crates/gamut-png/tests/c2pa.rs | 7 +- crates/gamut-png/tests/metadata.rs | 58 +++++++-- crates/gamut-png/tests/roundtrip.rs | 5 +- 6 files changed, 410 insertions(+), 47 deletions(-) diff --git a/crates/gamut-png/src/ancillary.rs b/crates/gamut-png/src/ancillary.rs index da7a0cd6..b3b1815d 100644 --- a/crates/gamut-png/src/ancillary.rs +++ b/crates/gamut-png/src/ancillary.rs @@ -34,6 +34,7 @@ //! sample inside the written range keeps its input-depth value. That is issue #501, not this //! module's claim. +use gamut_core::{Error, Result}; use gamut_deflate::{DeflateEncoder, Level}; use crate::{ColorType, chunk}; @@ -101,15 +102,45 @@ enum TextKind { Compressed, /// `iTXt`: uncompressed UTF-8. International, + /// `iTXt` with the compression flag set: zlib-compressed UTF-8. + InternationalCompressed, } +/// One accumulated text annotation, already **in the byte form its chunk carries**. +/// +/// The distinction is the whole point of holding bytes rather than `String`s. PNG's three text +/// chunks do not share a character set: §11.3.3.1 restricts a keyword to Latin-1 +/// ([ISO_8859-1]) in *every* one of them, §11.3.3.2 says a `tEXt` text string "is interpreted +/// according to the Latin-1 character set" (and §11.3.3.3 that inflating a `zTXt` "yields +/// Latin-1 text that is identical to the text that would be stored in an equivalent `tEXt` +/// chunk"), while §11.3.3.4 gives `iTXt` UTF-8. A Rust `String` is UTF-8, so writing its bytes +/// into a `tEXt` chunk stores mojibake for every code point above U+007F — `é` (U+00E9) becomes +/// the two bytes `C3 A9`, which a conforming reader shows as `é`. Converting once, at the point +/// the caller sets the text, makes that unrepresentable: an entry exists only if its bytes are +/// already right for its `kind`. #[derive(Debug, Clone)] struct TextEntry { - keyword: String, - text: String, + /// The keyword, Latin-1 (§11.3.3.1). + keyword: Vec, + /// The text: Latin-1 for `tEXt`/`zTXt`, UTF-8 for `iTXt`. + text: Vec, + /// The `iTXt` language tag (§11.3.3.4, BCP 47); empty for the other kinds and for an + /// unspecified language. + language: Vec, + /// The `iTXt` translated keyword (UTF-8, §11.3.3.4); empty for the other kinds. + translated: Vec, kind: TextKind, } +/// The Latin-1 bytes of `s`, or `None` when a character has no Latin-1 encoding. +/// +/// Latin-1 is the first 256 Unicode code points, so the encoding is `u8::try_from` on each +/// `char` — the exact inverse of the decoder's `latin1`, which maps byte *n* to U+00*nn*. A +/// string that came out of this crate's decoder therefore always converts back. +fn latin1_bytes(s: &str) -> Option> { + s.chars().map(|c| u8::try_from(u32::from(c)).ok()).collect() +} + /// Accumulated ancillary metadata to emit alongside the image. #[derive(Debug, Clone, Default)] pub(crate) struct Ancillary { @@ -119,6 +150,9 @@ pub(crate) struct Ancillary { pub chrm: Option<[u32; 8]>, /// sRGB: rendering-intent code. pub srgb: Option, + /// cICP: (colour primaries, transfer function, video full-range flag). The matrix + /// coefficients byte is not carried because §11.3.2.6 fixes it at 0 for PNG. + pub cicp: Option<(u8, u8, bool)>, /// sBIT: significant bits per channel (1–4 values, matching the colour type). pub sbit: Option>, /// bKGD: background colour, pre-serialised to its colour-type-specific bytes. @@ -136,6 +170,13 @@ pub(crate) struct Ancillary { pub c2pa: Option>, /// tEXt / zTXt / iTXt entries, emitted in insertion order. texts: Vec, + /// Whether a caller set a text annotation whose **keyword** has no Latin-1 encoding. + /// + /// §11.3.3.1 restricts a keyword to Latin-1 in all three text chunks, so — unlike the text, + /// which `iTXt` carries in UTF-8 — there is no chunk such a keyword fits. The entry is + /// dropped at the setter and the encode is refused by [`Self::validate`], rather than + /// silently writing a keyword no reader can match. + unencodable_keyword: bool, } impl Ancillary { @@ -164,18 +205,108 @@ impl Ancillary { self.push_text(keyword, text, TextKind::International); } + /// Adds an `iTXt` entry keeping its language tag and translated keyword (§11.3.3.4), which + /// [`add_text_international`](Self::add_text_international) leaves empty. Used only to carry + /// a decoded annotation forward, so that re-encoding a file does not silently drop the two + /// fields that make `iTXt` international. + pub(crate) fn add_text_international_tagged( + &mut self, + keyword: &str, + language: &str, + translated: &str, + text: &str, + ) { + if let Some(mut entry) = self.text_entry(keyword, text, TextKind::International) { + entry.language = language.as_bytes().to_vec(); + entry.translated = translated.as_bytes().to_vec(); + self.texts.push(entry); + } + } + fn push_text(&mut self, keyword: &str, text: &str, kind: TextKind) { - self.texts.push(TextEntry { - keyword: keyword.to_string(), - text: text.to_string(), + if let Some(entry) = self.text_entry(keyword, text, kind) { + self.texts.push(entry); + } + } + + /// Builds the entry for one text annotation, choosing the chunk that can actually carry it. + /// + /// The caller's `kind` is a *preference*, not a guarantee: §11.3.3.2 says outright that "text + /// containing characters outside the repertoire of ISO/IEC 8859-1 should be encoded using the + /// `iTXt` chunk", so a `tEXt`/`zTXt` request whose text is not Latin-1 is promoted to `iTXt` + /// rather than written as UTF-8 bytes a Latin-1 reader mis-renders. The promotion keeps the + /// caller's *other* choice — compression — because §11.3.3.4 gives `iTXt` a compression flag + /// of its own; only the character set changes. + /// + /// `None` (the entry is dropped, and [`Self::validate`] then refuses the encode) is reserved + /// for the one case no chunk can express: a keyword outside Latin-1. + fn text_entry(&mut self, keyword: &str, text: &str, kind: TextKind) -> Option { + let Some(keyword) = latin1_bytes(keyword) else { + self.unencodable_keyword = true; + return None; + }; + let (kind, text) = match (kind, latin1_bytes(text)) { + (TextKind::Latin1, Some(latin1)) => (TextKind::Latin1, latin1), + (TextKind::Compressed, Some(latin1)) => (TextKind::Compressed, latin1), + (TextKind::Latin1, None) => (TextKind::International, text.as_bytes().to_vec()), + (TextKind::Compressed, None) => { + (TextKind::InternationalCompressed, text.as_bytes().to_vec()) + } + (kind, _) => (kind, text.as_bytes().to_vec()), + }; + Some(TextEntry { + keyword, + text, + language: Vec::new(), + translated: Vec::new(), kind, - }); + }) + } + + /// Refuses an accumulation the spec says must not be written, before any byte is emitted. + /// + /// Two cases, both of which the caller stated explicitly and neither of which this encoder + /// may silently resolve for it: + /// + /// - **`sRGB` together with `iCCP`.** §5.6 Table 5 records the constraint on both rows — "if + /// the `iCCP` chunk is present, the `sRGB` chunk should not be present" and its converse — + /// and §11.3.2.5 repeats it ("it is recommended that the `sRGB` and `iCCP` chunks do not + /// appear simultaneously in a PNG datastream"). Emitting both is not undefined, because + /// §4.3 Table 1 ranks the colour chunks and a reader takes the lowest priority number + /// (`iCCP` 2 over `sRGB` 3) — but it *is* a datastream the standard tells encoders not to + /// produce, and which of the two the caller meant is not something this crate can guess. + /// Dropping one silently would lose colour information the caller supplied, so the encode + /// is refused. To carry both forward from a decoded file, use + /// [`PngEncoder::with_metadata`](crate::PngEncoder::with_metadata), which applies Table 1 + /// itself. + /// - **A text keyword outside Latin-1** (§11.3.3.1), which no text chunk can carry. + pub(crate) fn validate(&self) -> Result<()> { + if self.srgb.is_some() && self.iccp.is_some() { + return Err(Error::invalid_input( + env!("CARGO_PKG_NAME"), + "PNG: sRGB and iCCP must not both be written (spec §5.6 Table 5, §11.3.2.5); \ + set one", + )); + } + if self.unencodable_keyword { + return Err(Error::invalid_input( + env!("CARGO_PKG_NAME"), + "PNG: a text keyword must be Latin-1 (spec §11.3.3.1)", + )); + } + Ok(()) } /// 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; `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((primaries, transfer, full_range)) = self.cicp { + // §11.3.2.6 Table 18: primaries, transfer function, matrix coefficients, full-range + // flag — one byte each, the matrix fixed at 0 because "RGB is currently the only + // supported color model in PNG, and as such Matrix Coefficients shall be set to 0". + chunk::write_chunk(out, *b"cICP", &[primaries, transfer, 0, u8::from(full_range)]); + } if let Some(chrm) = self.chrm { let mut data = [0u8; 32]; for (slot, value) in chrm.iter().enumerate() { @@ -437,32 +568,42 @@ pub(crate) fn sbit_for(sbit: &[u8], color: ColorType, bit_depth: u8) -> Option, entry: &TextEntry, effort: u8) { + let compress = |payload: &[u8], data: &mut Vec| { + DeflateEncoder::new() + .with_level(Level::Best) + .with_effort(effort) + .zlib_compress(payload, data); + }; + let mut data = entry.keyword.clone(); + data.push(0); // null separator match entry.kind { TextKind::Latin1 => { - let mut data = entry.keyword.clone().into_bytes(); - data.push(0); // null separator - data.extend_from_slice(entry.text.as_bytes()); + data.extend_from_slice(&entry.text); chunk::write_chunk(out, *b"tEXt", &data); } TextKind::Compressed => { - let mut data = entry.keyword.clone().into_bytes(); - data.push(0); // null separator data.push(0); // compression method: 0 = zlib/deflate - DeflateEncoder::new() - .with_level(Level::Best) - .with_effort(effort) - .zlib_compress(entry.text.as_bytes(), &mut data); + compress(&entry.text, &mut data); chunk::write_chunk(out, *b"zTXt", &data); } - TextKind::International => { - let mut data = entry.keyword.clone().into_bytes(); - data.push(0); // null separator - data.push(0); // compression flag: 0 = uncompressed - data.push(0); // compression method - data.push(0); // empty language tag, then null - data.push(0); // empty translated keyword, then null - data.extend_from_slice(entry.text.as_bytes()); // UTF-8 text + TextKind::International | TextKind::InternationalCompressed => { + let compressed = entry.kind == TextKind::InternationalCompressed; + data.push(u8::from(compressed)); // compression flag + data.push(0); // compression method: 0 = zlib/deflate + data.extend_from_slice(&entry.language); + data.push(0); // language tag terminator + data.extend_from_slice(&entry.translated); + data.push(0); // translated keyword terminator + if compressed { + compress(&entry.text, &mut data); + } else { + data.extend_from_slice(&entry.text); + } chunk::write_chunk(out, *b"iTXt", &data); } } diff --git a/crates/gamut-png/src/decoder.rs b/crates/gamut-png/src/decoder.rs index 8a595eb4..562b4641 100644 --- a/crates/gamut-png/src/decoder.rs +++ b/crates/gamut-png/src/decoder.rs @@ -1636,7 +1636,6 @@ mod tests { #[test] fn rich_decode_surfaces_metadata_and_native_image() { - use crate::SrgbIntent; use crate::decoded::PngImage; let (w, h) = (6u32, 4u32); @@ -1647,7 +1646,9 @@ mod tests { let mut png = Vec::new(); PngEncoder::new() .with_gamma(1.0 / 2.2) - .with_srgb(SrgbIntent::Perceptual) + // cICP, not sRGB: the encoder refuses sRGB beside the iCCP this fixture needs + // (§5.6 Table 5, §11.3.2.5), while cICP is legal alongside it (§4.3 Table 1). + .with_cicp(9, 16, true) .with_chromaticities((0.3127, 0.3290), (0.64, 0.33), (0.30, 0.60), (0.15, 0.06)) .with_exif(&exif) .with_icc_profile("prof", b"not-a-real-profile-but-bytes") @@ -1670,7 +1671,7 @@ mod tests { other => panic!("expected Rgb8, got {other:?}"), } assert_eq!(decoded.gamma, Some(45455)); - assert_eq!(decoded.srgb, Some(SrgbIntent::Perceptual)); + assert!(decoded.srgb.is_none()); let chrm = decoded.chromaticities.unwrap(); assert_eq!(chrm.white, (31270, 32900)); assert_eq!(chrm.blue, (15000, 6000)); @@ -1690,7 +1691,16 @@ mod tests { assert_eq!(decoded.texts[1].text, comment); assert!(decoded.palette.is_none()); assert!(decoded.transparency.is_none()); - assert!(decoded.cicp.is_none()); + let cicp = decoded.cicp.expect("cICP present"); + assert_eq!( + ( + cicp.color_primaries, + cicp.transfer_function, + cicp.matrix_coefficients, + cicp.full_range + ), + (9, 16, 0, true) + ); } #[test] diff --git a/crates/gamut-png/src/encoder.rs b/crates/gamut-png/src/encoder.rs index b855978b..fd261996 100644 --- a/crates/gamut-png/src/encoder.rs +++ b/crates/gamut-png/src/encoder.rs @@ -31,6 +31,7 @@ use crate::ancillary::{ use crate::backend::{IdatDeflater, IdatInfo, Registry, run_deflaters}; use crate::chunk::{self, C2paSpan, SIGNATURE}; use crate::color::ColorType; +use crate::decoded::{Chromaticities, Cicp, DecodedPng, IccProfile, PngMetadata, TextChunk}; use crate::filter::{self, FilterStrategy, FilterType}; use crate::palette::PngPalette; use crate::reduce::{self, Reduced, Reductions}; @@ -74,6 +75,23 @@ pub struct PngEncodeReport { pub c2pa: Option, } +/// The metadata fields [`PngMetadata`] and [`DecodedPng`] both carry, borrowed. +/// +/// The two read surfaces agree field for field on purpose (one reads the pixels, one does not), +/// so [`PngEncoder::with_metadata`] and [`PngEncoder::with_metadata_from`] are the same function +/// over two shapes. Borrowing rather than cloning into a `PngMetadata` keeps a large ICC profile +/// or EXIF block from being copied twice on the way into the encoder. +struct MetadataView<'a> { + exif: Option<&'a [u8]>, + icc_profile: Option<&'a IccProfile>, + xmp: Option<&'a [u8]>, + texts: &'a [TextChunk], + gamma: Option, + chromaticities: Option, + srgb: Option, + cicp: Option, +} + /// A reusable PNG encoder. #[derive(Debug, Clone)] pub struct PngEncoder { @@ -208,12 +226,39 @@ impl PngEncoder { } /// Records the standard colour-space rendering intent (sRGB chunk). + /// + /// Mutually exclusive with [`with_icc_profile`](Self::with_icc_profile): PNG §5.6 Table 5 and + /// §11.3.2.5 both say the two chunks should not appear together, so setting both makes the + /// encode fail with [`Error::InvalidInput`] rather than write a file the standard tells + /// encoders not to produce. [`with_metadata`](Self::with_metadata) resolves the pair for you. #[must_use] pub fn with_srgb(mut self, intent: SrgbIntent) -> Self { self.ancillary.set_srgb(intent); self } + /// Records the video-signal colour space by its ITU-T H.273 code points (cICP chunk, + /// §11.3.2.6): the colour primaries, the transfer function, and whether the samples use the + /// full value range. + /// + /// There is no matrix-coefficients parameter because §11.3.2.6 fixes it: "RGB is currently + /// the only supported color model in PNG, and as such Matrix Coefficients shall be set to 0." + /// + /// cICP is the **highest-precedence** colour chunk (§4.3 Table 1, priority 1), so a reader + /// that understands it ignores any `iCCP`, `sRGB`, `gAMA` and `cHRM` in the same file. Those + /// stay legal alongside it — unlike the `sRGB`/`iCCP` pair — and are worth keeping as a + /// fallback for readers that do not. + #[must_use] + pub fn with_cicp( + mut self, + color_primaries: u8, + transfer_function: u8, + full_range: bool, + ) -> Self { + self.ancillary.cicp = Some((color_primaries, transfer_function, full_range)); + self + } + /// Records the white point and RGB primary chromaticities (cHRM chunk), each as `(x, y)`. #[must_use] pub fn with_chromaticities( @@ -351,8 +396,12 @@ impl PngEncoder { } /// Embeds an ICC colour profile (iCCP chunk), zlib-compressed. `profile` is the raw ICC profile - /// — for example the bytes produced by `gamut-icc`. (Mutually exclusive with [`Self::with_srgb`] - /// per the spec; set only one.) + /// — for example the bytes produced by `gamut-icc`. + /// + /// Mutually exclusive with [`with_srgb`](Self::with_srgb): PNG §5.6 Table 5 and §11.3.2.5 both + /// say the two chunks should not appear together, so setting both makes the encode fail with + /// [`Error::InvalidInput`] rather than write a file the standard tells encoders not to + /// produce. [`with_metadata`](Self::with_metadata) resolves the pair for you. #[must_use] pub fn with_icc_profile(mut self, name: &str, profile: &[u8]) -> Self { self.ancillary.iccp = Some((name.to_string(), profile.to_vec())); @@ -368,6 +417,131 @@ impl PngEncoder { self } + /// Carries every metadata chunk a [`PngMetadata`] holds into this encoder, so that + /// re-encoding a file keeps its EXIF, ICC profile, XMP packet, text annotations and colour + /// chunks instead of dropping them. + /// + /// This is the write-side counterpart of [`metadata`](crate::metadata): read a file's + /// metadata without touching its pixels, then hand it to the encoder that rewrites them. + /// [`with_metadata_from`](Self::with_metadata_from) is the same thing for a full + /// [`DecodedPng`]. + /// + /// # What it carries, and what it deliberately does not + /// + /// Everything the read side surfaces is set, with three spec-driven adjustments: + /// + /// - **`iCCP` and `sRGB` are resolved, not both written.** §4.3 Table 1 ranks the colour + /// chunks and a reader takes the lowest priority number, so the ICC profile (priority 2) + /// wins over the rendering intent (priority 3) and the `sRGB` chunk is dropped — which is + /// exactly the chunk a conforming reader would have ignored. Writing both is refused (§5.6 + /// Table 5, §11.3.2.5); this method is how a file carrying both is re-encoded at all. + /// - **A `cICP` whose matrix coefficients are not 0 is dropped.** §11.3.2.6 requires 0 for + /// PNG, so such a chunk is not conforming and copying it forward would reproduce the defect. + /// - **The C2PA manifest store is never carried.** A store is signed over the exact bytes of + /// the file it was made for, so copying it into a re-encode invalidates it by construction + /// — which is why `caBX` is *unsafe to copy* (C2PA 2.4 §A.3.2). Re-sign the output and set + /// it with [`with_c2pa`](Self::with_c2pa). + /// + /// Two further limits are the read side's, not this method's: `pHYs`, `tIME`, `sBIT` and + /// `bKGD` are not part of [`PngMetadata`], so they cannot be carried here (set them with + /// their own builder methods); and a `zTXt` is indistinguishable from a `tEXt` once decoded, + /// so a compressed annotation is rewritten uncompressed. Neither loses any text. + #[must_use] + pub fn with_metadata(self, metadata: &PngMetadata) -> Self { + self.with_metadata_view(MetadataView { + exif: metadata.exif.as_deref(), + icc_profile: metadata.icc_profile.as_ref(), + xmp: metadata.xmp.as_deref(), + texts: &metadata.texts, + gamma: metadata.gamma, + chromaticities: metadata.chromaticities, + srgb: metadata.srgb, + cicp: metadata.cicp, + }) + } + + /// Carries the metadata of a decoded file into this encoder: the [`DecodedPng`] twin of + /// [`with_metadata`](Self::with_metadata), which documents exactly what is and is not carried. + /// + /// Use this when you already decoded the pixels; use `with_metadata` when + /// [`metadata`](crate::metadata) read the file without them. + #[must_use] + pub fn with_metadata_from(self, decoded: &DecodedPng) -> Self { + self.with_metadata_view(MetadataView { + exif: decoded.exif.as_deref(), + icc_profile: decoded.icc_profile.as_ref(), + xmp: decoded.xmp.as_deref(), + texts: &decoded.texts, + gamma: decoded.gamma, + chromaticities: decoded.chromaticities, + srgb: decoded.srgb, + cicp: decoded.cicp, + }) + } + + /// The one implementation behind [`with_metadata`](Self::with_metadata) and + /// [`with_metadata_from`](Self::with_metadata_from). + fn with_metadata_view(mut self, meta: MetadataView<'_>) -> Self { + if let Some(exif) = meta.exif { + self = self.with_exif(exif); + } + // §4.3 Table 1: the reader honours the lowest priority number, iCCP (2) over sRGB (3). + // Writing both is what `Ancillary::validate` refuses, so pick the one that would have + // been honoured rather than hand the caller an error it cannot act on. + match (meta.icc_profile, meta.srgb) { + (Some(icc), _) => self = self.with_icc_profile(&icc.name, &icc.profile), + (None, Some(intent)) => self = self.with_srgb(intent), + (None, None) => {} + } + // §11.3.2.6: "Matrix Coefficients shall be set to 0". A source chunk that says otherwise + // is not a conforming cICP; carrying it forward would put the same defect in the output. + if let Some(cicp) = meta.cicp.filter(|cicp| cicp.matrix_coefficients == 0) { + self = self.with_cicp( + cicp.color_primaries, + cicp.transfer_function, + cicp.full_range, + ); + } + // Set in the stored ×100 000 fixed-point units rather than through `with_gamma` / + // `with_chromaticities`, whose `f64` arguments would round-trip the value through a + // division and a `round()`: preservation must be byte-exact. + if let Some(gamma) = meta.gamma { + self.ancillary.gamma = Some(gamma); + } + if let Some(chrm) = meta.chromaticities { + self.ancillary.chrm = Some([ + chrm.white.0, + chrm.white.1, + chrm.red.0, + chrm.red.1, + chrm.green.0, + chrm.green.1, + chrm.blue.0, + chrm.blue.1, + ]); + } + // The XMP packet is UTF-8 by §11.3.3.4; bytes that are not are not a packet this encoder + // can frame, and are dropped rather than written as an invalid iTXt. + if let Some(xmp) = meta.xmp.and_then(|bytes| str::from_utf8(bytes).ok()) { + self = self.with_xmp(xmp); + } + for text in meta.texts { + match (&text.language, &text.translated_keyword) { + // Neither field set: the annotation came from a tEXt/zTXt, or from an iTXt whose + // two optional fields were empty. Offer it as Latin-1 — which is byte-exact for + // the first case — and let `Ancillary` promote it to iTXt if the text needs it. + (None, None) => self.ancillary.add_text_latin1(&text.keyword, &text.text), + (language, translated) => self.ancillary.add_text_international_tagged( + &text.keyword, + language.as_deref().unwrap_or_default(), + translated.as_deref().unwrap_or_default(), + &text.text, + ), + } + } + self + } + /// Embeds a C2PA manifest store (the `caBX` chunk, C2PA 2.4 §A.3.2), verbatim and /// uncompressed, as the last chunk before `IDAT`. /// @@ -677,6 +851,10 @@ impl PngEncoder { pre_idat: F, out: &mut Vec, ) -> Result { + // Refuse an accumulation the spec says must not be written before emitting a byte, so a + // caller never receives a half-written buffer for a chunk set it chose (see + // [`Ancillary::validate`]). Every encode path funnels through here. + self.ancillary.validate()?; 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; diff --git a/crates/gamut-png/tests/c2pa.rs b/crates/gamut-png/tests/c2pa.rs index fc58e84f..0c577f0c 100644 --- a/crates/gamut-png/tests/c2pa.rs +++ b/crates/gamut-png/tests/c2pa.rs @@ -15,7 +15,7 @@ use common::{ }; use gamut_core::{DecodeImage, Dimensions, EncodeImage, ImageBuf, ImageRef, Indexed8, Rgb8, Rgba8}; use gamut_png::{ - PhysicalUnit, PngDecoder, PngEncoder, PngPalette, SegmentKind, SrgbIntent, deconstruct, + PhysicalUnit, PngDecoder, PngEncoder, PngPalette, SegmentKind, deconstruct, fill_c2pa, }; @@ -65,7 +65,10 @@ fn rgb_source() -> (Vec, Dimensions) { fn everything_else() -> PngEncoder { PngEncoder::new() .with_gamma(1.0 / 2.2) - .with_srgb(SrgbIntent::Perceptual) + // cICP rather than sRGB: §5.6 Table 5 and §11.3.2.5 say sRGB and iCCP must not both + // be written, and iCCP is the one whose payload has a size the store's placement depends + // on. cICP is legal alongside it (§4.3 Table 1 only ranks them). + .with_cicp(9, 16, true) .with_chromaticities((0.3127, 0.3290), (0.64, 0.33), (0.30, 0.60), (0.15, 0.06)) .with_icc_profile("Tiny", &tiny_icc_profile()) .with_significant_bits(&[8, 8, 8, 8]) diff --git a/crates/gamut-png/tests/metadata.rs b/crates/gamut-png/tests/metadata.rs index e63c8d94..dd8946d1 100644 --- a/crates/gamut-png/tests/metadata.rs +++ b/crates/gamut-png/tests/metadata.rs @@ -11,7 +11,7 @@ use common::{ chunk, ihdr_payload, minimal_png, png_from_chunks, tiny_exif, tiny_icc_profile, zlib, }; use gamut_core::{Dimensions, EncodeImage, ErrorKind, ImageRef, Rgb8}; -use gamut_png::{PngDecoder, PngEncoder, PngMetadata, SrgbIntent}; +use gamut_png::{PngDecoder, PngEncoder, PngMetadata}; /// A 2×2 RGB8 source for the encoder-driven tests. fn source() -> Vec { @@ -50,7 +50,9 @@ fn every_carrier_round_trips_byte_exact() { .with_compressed_text("Comment", "compressed comment") .with_international_text("Title", "international title") .with_gamma(1.0 / 2.2) - .with_srgb(SrgbIntent::RelativeColorimetric) + // cICP rather than sRGB, which §5.6 Table 5 and §11.3.2.5 forbid beside the iCCP + // this file also carries; sRGB's own carriage is pinned by `roundtrip.rs`. + .with_cicp(9, 16, true) .with_chromaticities( (0.3127, 0.3290), (0.6400, 0.3300), @@ -67,7 +69,16 @@ fn every_carrier_round_trips_byte_exact() { assert_eq!(meta.xmp.as_deref(), Some(xmp.as_bytes())); assert_eq!(meta.c2pa.as_deref(), Some(&c2pa[..])); assert_eq!(meta.gamma, Some(45_455)); - assert_eq!(meta.srgb, Some(SrgbIntent::RelativeColorimetric)); + let cicp = meta.cicp.expect("cICP present"); + assert_eq!( + ( + cicp.color_primaries, + cicp.transfer_function, + cicp.matrix_coefficients, + cicp.full_range + ), + (9, 16, 0, true) + ); let chrm = meta.chromaticities.expect("cHRM present"); assert_eq!(chrm.white, (31_270, 32_900)); assert_eq!(chrm.red, (64_000, 33_000)); @@ -84,17 +95,32 @@ fn every_carrier_round_trips_byte_exact() { /// and not the other fails here. #[test] fn metadata_agrees_with_decode_field_for_field() { + // Built chunk by chunk rather than by the encoder, so that *every* field is populated: the + // encoder refuses sRGB beside iCCP (§5.6 Table 5, §11.3.2.5), and a comparison of two `None`s + // would not see a chunk wired into one walk and not the other. A reader still meets such a + // file, and §13.1 says an ancillary chunk it cannot use is skipped, not fatal. let exif = tiny_exif(); let icc = tiny_icc_profile(); - let png = encode(|e| { - e.with_exif(&exif) - .with_icc_profile("Tiny", &icc) - .with_xmp("") - .with_c2pa(b"\0\0\0\x10jumbc2pa") - .with_text("Author", "nobody") - .with_gamma(1.0 / 2.2) - .with_srgb(SrgbIntent::Perceptual) - }); + let mut iccp = b"Tiny\0\0".to_vec(); + iccp.extend_from_slice(&zlib(&icc)); + let mut chrm = Vec::new(); + for coord in [31_270u32, 32_900, 64_000, 33_000, 30_000, 60_000, 15_000, 6_000] { + chrm.extend_from_slice(&coord.to_be_bytes()); + } + let png = png_from_chunks(&[ + chunk(b"IHDR", &ihdr_payload(3, 2, 8, 2, 0)), + chunk(b"eXIf", &exif), + chunk(b"iCCP", &iccp), + chunk(b"sRGB", &[1]), + chunk(b"cICP", &[1, 13, 0, 1]), + chunk(b"gAMA", &45_455u32.to_be_bytes()), + chunk(b"cHRM", &chrm), + chunk(b"tEXt", b"Author\0nobody"), + chunk(b"iTXt", b"XML:com.adobe.xmp\0\0\0\0\0"), + chunk(b"caBX", b"\0\0\0\x10jumbc2pa"), + chunk(b"IDAT", &zlib(&[0u8; 20])), + chunk(b"IEND", &[]), + ]); let meta = gamut_png::metadata(&png).unwrap(); let decoded = PngDecoder::new().decode(&png).unwrap(); @@ -109,10 +135,16 @@ fn metadata_agrees_with_decode_field_for_field() { assert_eq!(meta.chromaticities, decoded.chromaticities); assert_eq!(meta.srgb, decoded.srgb); assert_eq!(meta.cicp, decoded.cicp); + // A `None` on both sides would pass every comparison above, so pin that the file really did + // carry each field. + assert!(meta.exif.is_some() && meta.icc_profile.is_some() && meta.xmp.is_some()); + assert!(meta.c2pa.is_some() && !meta.texts.is_empty()); + assert!(meta.gamma.is_some() && meta.chromaticities.is_some()); + assert!(meta.srgb.is_some() && meta.cicp.is_some()); } /// The probe case from #379: cICP is uncompressed, so a colour-space probe costs a chunk walk and -/// nothing more. The encoder cannot write cICP, so the chunk is built by hand. +/// nothing more. Built by hand so the assertion reads the walk, not the encoder's own chunk. #[test] fn cicp_is_read_without_inflating_anything() { // BT.2020 primaries (9), PQ transfer (16), RGB matrix (0), full range. diff --git a/crates/gamut-png/tests/roundtrip.rs b/crates/gamut-png/tests/roundtrip.rs index f49aa436..75d810e8 100644 --- a/crates/gamut-png/tests/roundtrip.rs +++ b/crates/gamut-png/tests/roundtrip.rs @@ -258,7 +258,8 @@ fn ancillary_pile_survives_decode() { let (w, h) = (16u32, 16u32); let src = noise((w * h * 3) as usize, 9); let exif = tiny_exif(); - let icc = tiny_icc_profile(); + // No iCCP: it is the one chunk the encoder refuses beside the sRGB this pile carries (§5.6 + // Table 5, §11.3.2.5), and its carriage is pinned by `tests/metadata.rs`. let xmp = r#""#; let mut png = Vec::new(); PngEncoder::new() @@ -273,7 +274,6 @@ fn ancillary_pile_survives_decode() { .with_compressed_text("Comment", &"squeeze ".repeat(40)) .with_international_text("Author", "gämut") .with_exif(&exif) - .with_icc_profile("prof", &icc) .with_xmp(xmp) .encode_image( ImageRef::::new(&src, Dimensions::new(w, h).unwrap()).unwrap(), @@ -289,7 +289,6 @@ fn ancillary_pile_survives_decode() { assert_eq!(decoded.srgb, Some(SrgbIntent::RelativeColorimetric)); assert!(decoded.chromaticities.is_some()); assert_eq!(decoded.exif.as_deref(), Some(exif.as_slice())); - assert_eq!(decoded.icc_profile.unwrap().profile, icc); assert_eq!(decoded.xmp.as_deref(), Some(xmp.as_bytes())); assert_eq!(decoded.texts.len(), 3); } From d8c2ffdf4e859ec72311abc2af599d8c3aa8df73 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 00:44:55 -0400 Subject: [PATCH 77/94] test(png): pin Latin-1 text, the refused chunk pairs, and what a re-encode carries Inline in `ancillary.rs` where the assertion reads a non-pub item (`text_entry`, `validate`, `write_text`), and in `tests/preservation.rs` for the public `with_metadata` pair. Each names the function whose mutation it kills. Also wires `gamut convert` to carry the input's metadata on the PNG path, with `--strip-metadata` as the opt-out, pinned by a binary-driving test because `gamut-cli` is outside the mutation globs and the coverage regex. --- crates/gamut-cli/src/commands/convert.rs | 36 ++++- crates/gamut-cli/tests/convert_metadata.rs | 91 +++++++++++ crates/gamut-png/STATUS.md | 52 ++++++ crates/gamut-png/src/ancillary.rs | 154 +++++++++++++++++- crates/gamut-png/tests/c2pa.rs | 3 +- crates/gamut-png/tests/metadata.rs | 4 +- crates/gamut-png/tests/preservation.rs | 178 +++++++++++++++++++++ 7 files changed, 512 insertions(+), 6 deletions(-) create mode 100644 crates/gamut-cli/tests/convert_metadata.rs create mode 100644 crates/gamut-png/tests/preservation.rs diff --git a/crates/gamut-cli/src/commands/convert.rs b/crates/gamut-cli/src/commands/convert.rs index 7e7970aa..de5e6412 100644 --- a/crates/gamut-cli/src/commands/convert.rs +++ b/crates/gamut-cli/src/commands/convert.rs @@ -1,6 +1,6 @@ //! `gamut convert` — decode an image and re-encode it with a gamut codec. -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use clap::{Args, ValueEnum}; use gamut::avif::AvifEncoder; @@ -86,6 +86,14 @@ pub(crate) struct ConvertArgs { /// for other output formats. #[arg(long)] jxl_container: bool, + /// Drop the input's metadata instead of carrying it into the output. By default a PNG input + /// re-encoded to PNG keeps its EXIF, ICC profile, XMP packet, text annotations and colour + /// chunks; a stripped file is smaller, an unstripped one is colour-accurate, so the default + /// is the one that loses nothing. The C2PA manifest store is never carried either way (it is + /// signed over the bytes of the file it was made for). Currently applies only to the PNG + /// output path with a PNG input; every other pair drops metadata regardless. + #[arg(long)] + strip_metadata: bool, } /// Output container/codec for `gamut convert`. @@ -242,6 +250,22 @@ pub(crate) fn run(args: &ConvertArgs) -> Result<(), CliError> { if let Some(effort) = args.png_effort { encoder = encoder.with_effort(effort); } + // Carry the input's metadata rather than dropping it (issue #483). `png_metadata` + // reads the file a second time — cheaply: the walk skips IDAT by length and never + // inflates a pixel — and yields nothing for an input that is not a PNG. + let metadata = (!args.strip_metadata) + .then(|| png_metadata(&args.input)) + .flatten(); + if let Some(metadata) = &metadata { + tracing::info!( + texts = metadata.texts.len(), + exif = metadata.exif.is_some(), + icc = metadata.icc_profile.is_some(), + xmp = metadata.xmp.is_some(), + "carrying input metadata" + ); + encoder = encoder.with_metadata(metadata); + } encoder.encode_image(ImageRef::::new(&rgba, dims)?, &mut out)?; (rgba.len(), dims) } @@ -324,6 +348,16 @@ pub(crate) fn run(args: &ConvertArgs) -> Result<(), CliError> { Ok(()) } +/// The metadata `path` carries, or `None` when it is not a PNG or cannot be read. +/// +/// Deliberately total: the input has already been decoded successfully by the time this is +/// called, so an error here means the file is simply not a PNG — a JPEG or WebP input has +/// metadata of its own, but mapping that into PNG chunks is a cross-format job this command does +/// not do yet. Failing to *read* metadata must never fail a conversion whose pixels are fine. +fn png_metadata(path: &Path) -> Option { + gamut::png::metadata(&std::fs::read(path).ok()?).ok() +} + /// Picks the output format from `--format`, falling back to the output file's extension. fn resolve_format(args: &ConvertArgs) -> Result { if let Some(format) = args.format { diff --git a/crates/gamut-cli/tests/convert_metadata.rs b/crates/gamut-cli/tests/convert_metadata.rs new file mode 100644 index 00000000..947ceb2d --- /dev/null +++ b/crates/gamut-cli/tests/convert_metadata.rs @@ -0,0 +1,91 @@ +//! End-to-end tests for what `gamut convert` does with the input's metadata on the PNG path +//! (issue #483): carried by default, dropped under `--strip-metadata`. +//! +//! These drive the built `gamut` binary (`CARGO_BIN_EXE_gamut`) rather than calling the command +//! function, because `crates/gamut-cli` is outside both the mutation globs and the coverage +//! regex — behaviour pinned only by a unit test here is pinned nowhere the gates can see. The +//! encoder-side claims are pinned in `gamut-png`; what this file adds is that the CLI wires them +//! up at all, which is exactly the gap the issue reported (0% metadata round-trip). + +use std::path::PathBuf; +use std::process::Command; + +use gamut::core::{Dimensions, EncodeImage, ImageRef, Rgba8}; +use gamut::png::{PngEncoder, PngMetadata, SrgbIntent}; + +/// A 2×2 PNG carrying an EXIF block, a text annotation and a rendering intent. +fn png_with_metadata() -> Vec { + let rgba = vec![255u8; 4 * 4]; + let dims = Dimensions { + width: 2, + height: 2, + }; + let image = ImageRef::::new(&rgba, dims).unwrap(); + PngEncoder::new() + .with_exif(&[0x49, 0x49, 0x2A, 0x00, 0x08, 0x00, 0x00, 0x00]) + .with_text("Author", "nobody") + .with_srgb(SrgbIntent::Perceptual) + .encode_to_vec(image) + .unwrap() +} + +/// Writes `png` to a temp file, converts it to PNG with `extra` flags, and returns the output's +/// metadata. Both temp files are removed before the assertion runs. +fn convert(name: &str, png: &[u8], extra: &[&str]) -> PngMetadata { + let dir = std::env::temp_dir(); + let input = dir.join(format!( + "gamut-convert-{}-{name}-in.png", + std::process::id() + )); + let output: PathBuf = dir.join(format!( + "gamut-convert-{}-{name}-out.png", + std::process::id() + )); + std::fs::write(&input, png).unwrap(); + + let status = Command::new(env!("CARGO_BIN_EXE_gamut")) + .arg("convert") + .arg(&input) + .arg(&output) + .args(extra) + .output() + .expect("run gamut convert"); + let encoded = std::fs::read(&output).ok(); + let _ = std::fs::remove_file(&input); + let _ = std::fs::remove_file(&output); + + assert!( + status.status.success(), + "stderr: {}", + String::from_utf8_lossy(&status.stderr) + ); + gamut::png::metadata(&encoded.expect("output written")).expect("read back") +} + +/// The issue's headline: `gamut convert` used to decode to raw RGBA and encode with a bare +/// builder, so every EXIF, ICC, XMP and text chunk was lost with no warning. +#[test] +fn png_to_png_carries_the_input_metadata_by_default() { + let meta = convert("default", &png_with_metadata(), &[]); + + assert_eq!( + meta.exif.as_deref(), + Some(&[0x49, 0x49, 0x2A, 0x00, 0x08, 0x00, 0x00, 0x00][..]) + ); + assert_eq!(meta.srgb, Some(SrgbIntent::Perceptual)); + let texts: Vec<(&str, &str)> = meta + .texts + .iter() + .map(|t| (t.keyword.as_str(), t.text.as_str())) + .collect(); + assert_eq!(texts, [("Author", "nobody")]); +} + +/// The opt-out: a stripped file is smaller, which is why the flag exists, but it has to be asked +/// for — the default may not silently discard colour information. +#[test] +fn strip_metadata_drops_it_all() { + let meta = convert("stripped", &png_with_metadata(), &["--strip-metadata"]); + + assert_eq!(meta, PngMetadata::default()); +} diff --git a/crates/gamut-png/STATUS.md b/crates/gamut-png/STATUS.md index 260f098a..7e033458 100644 --- a/crates/gamut-png/STATUS.md +++ b/crates/gamut-png/STATUS.md @@ -41,6 +41,8 @@ opts into narrowing. That is distinct from the encoder's *lossless* auto-reduce | 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 | | C1 | C2PA 2.4 §A.3.2, §18.5.4 | **C2PA carriage** (#440): the `caBX` manifest store — raw decode surface (`c2pa`; first CRC-valid chunk before `IDAT` wins, ignored ones counted, under the metadata budget); `with_c2pa` / `with_c2pa_reserved` as the last chunk before `IDAT`; the whole-chunk exclusion span from `encode_with_report` and `PngReport::c2pa`, filled in place by `fill_c2pa` (see [C2PA](#c2pa-manifest-store-issue-440)) | ✅ done | +| M1 | §4.3, §5.6, §11.3.2.6, §11.3.3 | **Metadata preservation** (#483): `with_metadata` / `with_metadata_from` carry a read file's eXIf/iCCP/XMP/text/colour chunks into a re-encode (`gamut convert` uses it; `--strip-metadata` opts out); `with_cicp`; `sRGB` beside `iCCP` refused and resolved by colour-chunk priority; `tEXt`/`zTXt` written as Latin-1 with promotion to `iTXt` (see [Metadata preservation](#metadata-preservation-issue-483)) | ✅ done | + ## Decoder phases (issue #249) | Phase | Spec | Scope | Status | @@ -135,6 +137,56 @@ hash assertion can be checked over the excluded span) is issue #447. of any kind. `gamut convert` does not carry a store across a re-encode (that is the facade's `C2paPolicy` law, and the CLI's own path is #448/#483). +## Metadata preservation (issue #483) + +The read side has surfaced every metadata payload since D5, and the write side has accepted every +one since P8, but nothing joined them: a re-encode dropped all of it, so `gamut convert`'s PNG +path round-tripped 0% of a file's metadata. + +`PngEncoder::with_metadata(&PngMetadata)` and `with_metadata_from(&DecodedPng)` are that join — +one private borrowed view behind two entry points, so the pixel-free `metadata()` walk and a full +`decode()` reach it without copying a large ICC profile twice. `gamut convert` uses it on the PNG +output path; `--strip-metadata` is the opt-out. **Preserve is the default**: a stripped file is +smaller, but dropping an ICC profile silently changes what a viewer paints, so the loss is the +thing that has to be asked for. + +**Three spec-driven adjustments** on the way through, none of them a policy choice: + +- `iCCP` and `sRGB` are **resolved, not both written**. §5.6 Table 5 records the constraint on both + rows and §11.3.2.5 repeats it; §4.3 Table 1 then ranks the colour chunks (cICP 1, iCCP 2, sRGB 3, + cHRM+gAMA 4) and a reader honours the lowest number. So the `iCCP` is carried and the `sRGB` + dropped — the chunk a conforming reader was already ignoring. +- A `cICP` whose matrix coefficients are not 0 is dropped: §11.3.2.6 requires 0 for PNG, so such a + chunk is not conforming and carrying it forward would reproduce the defect. +- The **C2PA manifest store is never carried**. A store is signed over the exact bytes of the file + it was made for — the reason `caBX` is unsafe to copy (C2PA 2.4 §A.3.2) — so a copy is invalid by + construction. Re-sign the output and set it with `with_c2pa`. + +**Two spec defects** the same issue found, both in the writer: + +- *`sRGB` beside `iCCP` was written whenever both were set*, warned about only in a doc comment. + Now `Ancillary::validate` refuses the encode with `InvalidInput` at the one chokepoint every + encode path funnels through. Refusing rather than dropping one is the point: which the caller + meant is not guessable, and `with_metadata` exists for the case where §4.3 answers it. +- *`tEXt`/`zTXt` carried UTF-8.* §11.3.3.2 interprets a `tEXt` text string as Latin-1, §11.3.3.3 + makes an inflated `zTXt` identical to it, and §11.3.3.1 binds every keyword to Latin-1 — but the + writer pushed the Rust `String`'s bytes, storing `C3 A9` where `é` belongs. Text and keyword are + now converted once at the setter and the entry holds the bytes its chunk carries, so the wrong + encoding is unrepresentable rather than merely avoided. A text outside Latin-1 is promoted to + `iTXt` exactly as §11.3.3.2 directs, keeping the caller's compression via §11.3.3.4's flag; a + *keyword* outside it has no chunk at all, so it refuses the encode. + +`with_cicp` (§11.3.2.6) was added with this work — without it, preservation would silently drop the +highest-precedence colour chunk of any file that carries one. It takes no matrix argument: PNG +fixes that byte at 0. + +**Not done.** `pHYs`, `tIME`, `sBIT` and `bKGD` are not part of `PngMetadata`/`DecodedPng`, so they +cannot be carried (set them with their own builder methods). A `zTXt` is indistinguishable from a +`tEXt` once decoded, so a compressed annotation is rewritten uncompressed — no text is lost, only +bytes. §11.3.3.1's keyword *syntax* rules beyond Latin-1 (the printable subset, the space rules, +the 1–79-byte bound) are not enforced. `gamut convert` carries metadata only PNG→PNG; mapping a +JPEG/WebP/JXL input's metadata into PNG chunks is a cross-format job of its own. + ## Efficiency (issue #224) Correctness was settled long before efficiency was measured. This section is the measured state: diff --git a/crates/gamut-png/src/ancillary.rs b/crates/gamut-png/src/ancillary.rs index b3b1815d..23015801 100644 --- a/crates/gamut-png/src/ancillary.rs +++ b/crates/gamut-png/src/ancillary.rs @@ -305,7 +305,11 @@ impl Ancillary { // §11.3.2.6 Table 18: primaries, transfer function, matrix coefficients, full-range // flag — one byte each, the matrix fixed at 0 because "RGB is currently the only // supported color model in PNG, and as such Matrix Coefficients shall be set to 0". - chunk::write_chunk(out, *b"cICP", &[primaries, transfer, 0, u8::from(full_range)]); + chunk::write_chunk( + out, + *b"cICP", + &[primaries, transfer, 0, u8::from(full_range)], + ); } if let Some(chrm) = self.chrm { let mut data = [0u8; 32]; @@ -567,7 +571,6 @@ pub(crate) fn sbit_for(sbit: &[u8], color: ColorType, bit_depth: u8) -> Option]) -> Vec { + let mut iccp = b"Tiny\0\0".to_vec(); + iccp.extend_from_slice(&zlib(&tiny_icc_profile())); + let mut chrm = Vec::new(); + for coord in CHRM { + chrm.extend_from_slice(&coord.to_be_bytes()); + } + let mut chunks = vec![ + chunk(b"IHDR", &ihdr_payload(3, 2, 8, 2, 0)), + chunk(b"eXIf", &tiny_exif()), + chunk(b"iCCP", &iccp), + chunk(b"gAMA", &45_455u32.to_be_bytes()), + chunk(b"cHRM", &chrm), + chunk(b"tEXt", b"Author\0caf\xE9"), + chunk(b"iTXt", b"Note\0\0\0de\0Notiz\0g\xC3\xA4mut"), + chunk(b"iTXt", b"XML:com.adobe.xmp\0\0\0\0\0"), + chunk(b"caBX", b"\0\0\0\x10jumbc2pa"), + ]; + chunks.extend_from_slice(extra); + chunks.push(chunk(b"IDAT", &zlib(&[0u8; 20]))); + chunks.push(chunk(b"IEND", &[])); + png_from_chunks(&chunks) +} + +/// Re-encodes a 2×2 image under `build`, and reads back what the output carries. +fn re_encoded(build: impl FnOnce(PngEncoder) -> PngEncoder) -> PngMetadata { + let pixels = vec![0u8; 3 * 4]; + let image = ImageRef::::new(&pixels, Dimensions::new(2, 2).unwrap()).unwrap(); + let png = build(PngEncoder::new()) + .encode_to_vec(image) + .expect("re-encode"); + gamut_png::metadata(&png).expect("read back") +} + +/// The headline claim of #483: nothing the read side surfaced is dropped on the way back out. +/// Before this, `gamut convert`'s PNG path round-tripped 0% of it. +#[test] +fn every_carried_chunk_survives_a_re_encode() { + let meta = gamut_png::metadata(&source(&[])).unwrap(); + let re = re_encoded(|e| e.with_metadata(&meta)); + + assert_eq!(re.exif, meta.exif); + assert_eq!(re.icc_profile, meta.icc_profile); + assert_eq!(re.xmp, meta.xmp); + assert_eq!(re.gamma, Some(45_455)); + let chrm = re.chromaticities.expect("cHRM carried"); + assert_eq!( + (chrm.white, chrm.blue), + ((CHRM[0], CHRM[1]), (CHRM[6], CHRM[7])) + ); + // Both text annotations, in file order, with the Latin-1 `é` intact. + let texts: Vec<(&str, &str)> = re + .texts + .iter() + .map(|t| (t.keyword.as_str(), t.text.as_str())) + .collect(); + assert_eq!(texts, [("Author", "café"), ("Note", "gämut")]); +} + +/// §11.3.3.4's language tag and translated keyword are what make an `iTXt` international; a +/// re-encode that reduced every annotation to a bare keyword and text would silently strip them. +#[test] +fn an_itxt_keeps_its_language_and_translated_keyword() { + let meta = gamut_png::metadata(&source(&[])).unwrap(); + let re = re_encoded(|e| e.with_metadata(&meta)); + + let note = re.texts.iter().find(|t| t.keyword == "Note").expect("Note"); + assert_eq!(note.language.as_deref(), Some("de")); + assert_eq!(note.translated_keyword.as_deref(), Some("Notiz")); +} + +/// §4.3 Table 1 ranks the colour chunks and a reader honours the lowest priority number, so of a +/// source carrying both the `iCCP` (2) is the chunk that was being used and the `sRGB` (3) the +/// chunk that was being ignored. Carrying both would be the pair §5.6 Table 5 and §11.3.2.5 +/// refuse, and would make the file unencodable. +#[test] +fn srgb_gives_way_to_an_icc_profile_from_the_same_file() { + let meta = gamut_png::metadata(&source(&[chunk(b"sRGB", &[1])])).unwrap(); + assert_eq!(meta.srgb, Some(SrgbIntent::RelativeColorimetric)); + assert!(meta.icc_profile.is_some(), "the source carries both"); + + let re = re_encoded(|e| e.with_metadata(&meta)); + assert!(re.icc_profile.is_some(), "the ICC profile is kept"); + assert!(re.srgb.is_none(), "the lower-priority sRGB is dropped"); +} + +/// The converse: with no ICC profile to outrank it, the rendering intent is the colour +/// information the file has, and dropping it would lose it. +#[test] +fn srgb_is_carried_when_no_icc_profile_outranks_it() { + let png = png_from_chunks(&[ + chunk(b"IHDR", &ihdr_payload(3, 2, 8, 2, 0)), + chunk(b"sRGB", &[2]), + chunk(b"IDAT", &zlib(&[0u8; 20])), + chunk(b"IEND", &[]), + ]); + let meta = gamut_png::metadata(&png).unwrap(); + + let re = re_encoded(|e| e.with_metadata(&meta)); + assert_eq!(re.srgb, Some(SrgbIntent::Saturation)); +} + +/// §11.3.2.6: "RGB is currently the only supported color model in PNG, and as such Matrix +/// Coefficients shall be set to 0." A source chunk that says otherwise is not a conforming cICP, +/// so it is dropped rather than reproduced — while a conforming one is carried, which matters +/// because §4.3 Table 1 makes cICP the *highest*-precedence colour chunk. +#[test] +fn a_cicp_is_carried_only_when_its_matrix_coefficients_are_zero() { + let conforming = gamut_png::metadata(&source(&[chunk(b"cICP", &[9, 16, 0, 1])])).unwrap(); + let carried = re_encoded(|e| e.with_metadata(&conforming)) + .cicp + .expect("cICP carried"); + assert_eq!( + ( + carried.color_primaries, + carried.transfer_function, + carried.matrix_coefficients, + carried.full_range + ), + (9, 16, 0, true) + ); + + let non_rgb = gamut_png::metadata(&source(&[chunk(b"cICP", &[9, 16, 1, 1])])).unwrap(); + assert!(non_rgb.cicp.is_some(), "the source carries it"); + assert!(re_encoded(|e| e.with_metadata(&non_rgb)).cicp.is_none()); +} + +/// Drift guard. A C2PA manifest store is signed over the exact bytes of the file it was made for, +/// which is why C2PA 2.4 §A.3.2 marks `caBX` unsafe to copy: carried into a re-encode it is +/// invalid by construction, and a validator would report a tampered file rather than an unsigned +/// one. This asserts the omission is deliberate, because adding one line would undo it silently. +#[test] +fn the_c2pa_manifest_store_is_never_carried_forward() { + let meta = gamut_png::metadata(&source(&[])).unwrap(); + assert!(meta.c2pa.is_some(), "the source carries a store"); + + assert!(re_encoded(|e| e.with_metadata(&meta)).c2pa.is_none()); +} + +/// The two entry points differ only in which read surface they take, so a field wired into one +/// and not the other is a bug this catches — the same anti-drift shape `tests/metadata.rs` uses +/// for the two *read* walks. +#[test] +fn with_metadata_from_agrees_with_with_metadata() { + let png = source(&[chunk(b"cICP", &[9, 16, 0, 1])]); + let decoded = PngDecoder::new().decode(&png).unwrap(); + let meta = gamut_png::metadata(&png).unwrap(); + + let from_decoded = re_encoded(|e| e.with_metadata_from(&decoded)); + let from_metadata = re_encoded(|e| e.with_metadata(&meta)); + assert_eq!(from_decoded, from_metadata); + // A pair of empty results would satisfy the comparison above. + assert!(from_decoded.icc_profile.is_some() && from_decoded.cicp.is_some()); + assert!(!from_decoded.texts.is_empty() && from_decoded.exif.is_some()); +} From d7868bc39c067767e71a7d9782cd68dbf2d108e6 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 01:35:28 -0400 Subject: [PATCH 78/94] =?UTF-8?q?fix(png)!:=20implement=20=C2=A711.3.3's?= =?UTF-8?q?=20text=20clauses=20and=20stop=20refusing=20the=20colour=20pair?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The keyword rule shipped as "code point under 256", which is neither of the clauses PNG states. §11.3.3.1 binds a keyword to code points 0x20-0x7E and 0xA1-0xFF, 1 to 79 bytes, with no leading, trailing or consecutive space and expressly not U+00A0; §11.3.3.1's closing paragraph restricts a tEXt/zTXt text string to that repertoire plus U+000A. Both are now implemented as written, so an empty keyword, a 200-byte one, U+00A0, 0x7F and 0x9F no longer pass, and a control character promotes to iTXt with everything else outside the repertoire rather than being written with no defined meaning. A null was accepted anywhere. It is the field separator, so `Auth\0or` does not merely offend the grammar — the chunk re-parses as a *different* annotation. §11.3.3.2 forbids it in a tEXt keyword and text string and §11.3.3.4 in an iTXt's text and translated keyword; all four are refused, as is a language tag outside BCP 47's subtag characters and an XMP packet that is not UTF-8. The refusal names the annotation's index and its keyword through the owned-context error channel, so a caller can act on it. The sRGB-beside-iCCP refusal goes. §5.6 Table 5 and §11.3.2.5 say only "should not" and "it is recommended", and §15 gives the BCP 14 keywords force "when, and only when, they appear in all capitals"; §4.3 Table 1 presupposes the pair and defines the outcome by ranking the chunks. libpng reads a file carrying both and returns the same pixels, which `tests/oracle.rs` now pins — so the four in-repo fixtures that had to be rewritten around the refusal are restored. BREAKING CHANGE: a text annotation whose keyword or text breaks §11.3.3 now fails the encode with `Error::InvalidInput` instead of being written. Keywords that were accepted before and are not now: empty, longer than 79 bytes, containing a null, a control character or U+00A0, and any with a leading, trailing or consecutive space. --- crates/gamut-png/src/ancillary.rs | 613 ++++++++++++++++++++----- crates/gamut-png/src/decoded.rs | 47 +- crates/gamut-png/src/decoder.rs | 6 +- crates/gamut-png/src/encoder.rs | 219 ++++++--- crates/gamut-png/src/lib.rs | 3 +- crates/gamut-png/tests/c2pa.rs | 8 +- crates/gamut-png/tests/metadata.rs | 51 +- crates/gamut-png/tests/oracle.rs | 41 ++ crates/gamut-png/tests/preservation.rs | 192 ++++++-- crates/gamut-png/tests/roundtrip.rs | 5 +- 10 files changed, 924 insertions(+), 261 deletions(-) diff --git a/crates/gamut-png/src/ancillary.rs b/crates/gamut-png/src/ancillary.rs index 23015801..19e179ed 100644 --- a/crates/gamut-png/src/ancillary.rs +++ b/crates/gamut-png/src/ancillary.rs @@ -37,9 +37,10 @@ use gamut_core::{Error, Result}; use gamut_deflate::{DeflateEncoder, Level}; +use crate::decoded::XMP_KEYWORD; use crate::{ColorType, chunk}; -/// The rendering intent for an `sRGB` chunk (PNG spec §11.3.3.5). +/// The rendering intent for an `sRGB` chunk (PNG spec §11.3.2.5). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SrgbIntent { /// Perceptual (intent code 0). @@ -106,6 +107,20 @@ enum TextKind { InternationalCompressed, } +impl TextKind { + /// The `iTXt` kind that carries the same compression choice. + /// + /// §11.3.3.2 sends text outside Latin-1's repertoire to `iTXt`, and §11.3.3.4 gives `iTXt` a + /// compression flag of its own, so a promotion changes the character set and nothing else — + /// a compressed annotation stays compressed. + fn international(self) -> Self { + match self { + Self::Latin1 | Self::International => Self::International, + Self::Compressed | Self::InternationalCompressed => Self::InternationalCompressed, + } + } +} + /// One accumulated text annotation, already **in the byte form its chunk carries**. /// /// The distinction is the whole point of holding bytes rather than `String`s. PNG's three text @@ -116,11 +131,12 @@ enum TextKind { /// chunk"), while §11.3.3.4 gives `iTXt` UTF-8. A Rust `String` is UTF-8, so writing its bytes /// into a `tEXt` chunk stores mojibake for every code point above U+007F — `é` (U+00E9) becomes /// the two bytes `C3 A9`, which a conforming reader shows as `é`. Converting once, at the point -/// the caller sets the text, makes that unrepresentable: an entry exists only if its bytes are -/// already right for its `kind`. +/// the caller sets the text, makes that unrepresentable: an entry's bytes are always already +/// right for its `kind`, or it carries the [`fault`](Self::fault) that stops it being written. #[derive(Debug, Clone)] struct TextEntry { - /// The keyword, Latin-1 (§11.3.3.1). + /// The keyword, Latin-1 (§11.3.3.1). Empty when [`fault`](Self::fault) is set, because such + /// an entry is never written — [`Ancillary::validate`] refuses the encode first. keyword: Vec, /// The text: Latin-1 for `tEXt`/`zTXt`, UTF-8 for `iTXt`. text: Vec, @@ -130,15 +146,116 @@ struct TextEntry { /// The `iTXt` translated keyword (UTF-8, §11.3.3.4); empty for the other kinds. translated: Vec, kind: TextKind, + /// Whether this entry came from [`Ancillary::begin_carry`] rather than a direct setter, so a + /// second carry can replace exactly what the first contributed. + carried: bool, + /// Why this annotation must not be written, if it must not. Recorded here rather than + /// returned from the setter because the setters sit behind `#[must_use]` builder methods + /// that have no error channel; [`Ancillary::validate`] reports it at the encode chokepoint. + fault: Option, +} + +/// Why one accumulated text annotation cannot be written, and which annotation it was. +#[derive(Debug, Clone)] +struct TextFault { + /// The keyword exactly as the caller gave it, for the refusal message — including a keyword + /// that is itself the fault. + keyword: String, + /// The clause the annotation breaks, phrased for the caller. + reason: &'static str, +} + +/// §11.3.3.1: "Keywords are restricted to 1 to 79 bytes in length." +const KEYWORD_LENGTH: &str = "a keyword is restricted to 1 to 79 bytes (§11.3.3.1)"; +/// §11.3.3.1: "Keywords shall contain only printable Latin-1 [ISO_8859-1] characters and spaces; +/// that is, only code points 0x20-7E and 0xA1-FF are allowed", and expressly "nor is U+00A0 +/// NON-BREAKING SPACE". A null is outside it too, which is also §11.3.3.2's "Neither the keyword +/// nor the text string may contain a null character". +const KEYWORD_REPERTOIRE: &str = "a keyword may hold only code points 0x20-0x7E and 0xA1-0xFF \ + — no null, no control character, not U+00A0 (§11.3.3.1)"; +/// §11.3.3.1: "leading spaces, trailing spaces, and consecutive spaces are not permitted in +/// keywords". +const KEYWORD_SPACES: &str = + "a keyword may not have a leading, trailing or consecutive space (§11.3.3.1)"; +/// §11.3.3.2 for `tEXt`/`zTXt` ("Neither the keyword nor the text string may contain a null +/// character") and §11.3.3.4 for `iTXt` ("neither shall contain a zero byte"). The null is the +/// field separator, so an embedded one does not merely offend the grammar — the chunk re-parses +/// as a *different* annotation. +const TEXT_NUL: &str = "a text string may not contain a null character (§11.3.3.2, §11.3.3.4)"; +/// §11.3.3.4: "The language tag is a well-formed language tag defined by [BCP47]", whose subtags +/// are ASCII letters and digits joined by hyphens. Anything else is neither well-formed nor +/// (being written as UTF-8 and read back as Latin-1) byte-exact. +const LANGUAGE_TAG: &str = + "an iTXt language tag may hold only ASCII letters, digits and '-' (§11.3.3.4, BCP 47)"; +/// §11.3.3.4: "The translated keyword and text both use the UTF-8 encoding, and neither shall +/// contain a zero byte (null character)." +const TRANSLATED_NUL: &str = + "an iTXt translated keyword may not contain a null character (§11.3.3.4)"; +/// §11.3.3.4 gives the `iTXt` text field UTF-8 and no other encoding, so a packet that is not +/// UTF-8 has no chunk to go in. Dropping it silently is the loss this crate refuses to make. +const XMP_NOT_UTF8: &str = + "the XMP packet is not UTF-8, and an iTXt text string must be (§11.3.3.4)"; + +/// Whether `c` is a printable Latin-1 character or a space, the repertoire §11.3.3.1 spells out +/// as "only code points 0x20-7E and 0xA1-FF". +fn printable_latin1(c: char) -> bool { + matches!(u32::from(c), 0x20..=0x7E | 0xA1..=0xFF) +} + +/// Whether `c` may appear in a `tEXt`/`zTXt` **text string**: §11.3.3.1's closing paragraph +/// restricts their content to "the printable Latin-1 character set plus U+000A LINE FEED (LF)". +/// +/// §11.3.3.2 says more loosely that the text "may contain any Latin-1 character", which would +/// admit the C0/C1 controls and U+00A0. The tighter reading costs nothing to take: a character +/// outside this set is not rejected, it is *promoted* to `iTXt` — exactly what §11.3.3.2's own +/// "Text containing characters outside the repertoire of ISO/IEC 8859-1 should be encoded using +/// the iTXt chunk" directs — so the character always survives and only the chunk changes. +fn text_repertoire(c: char) -> bool { + c == '\n' || printable_latin1(c) +} + +/// The Latin-1 byte of `c`: Latin-1 is the first 256 Unicode code points, so the encoding is +/// `u8::try_from` — the exact inverse of the decoder's `latin1`, which maps byte *n* to U+00*nn*. +fn latin1_byte(c: char) -> Option { + u8::try_from(u32::from(c)).ok() } -/// The Latin-1 bytes of `s`, or `None` when a character has no Latin-1 encoding. +/// The Latin-1 bytes of a keyword, or the §11.3.3.1 clause it breaks. /// -/// Latin-1 is the first 256 Unicode code points, so the encoding is `u8::try_from` on each -/// `char` — the exact inverse of the decoder's `latin1`, which maps byte *n* to U+00*nn*. A -/// string that came out of this crate's decoder therefore always converts back. -fn latin1_bytes(s: &str) -> Option> { - s.chars().map(|c| u8::try_from(u32::from(c)).ok()).collect() +/// The repertoire is checked before the length so that the length bound counts *stored* bytes: +/// every character that passes is one Latin-1 byte, which a UTF-8 `str::len` is not. +fn keyword_bytes(keyword: &str) -> core::result::Result, &'static str> { + let bytes: Option> = keyword + .chars() + .map(|c| latin1_byte(c).filter(|_| printable_latin1(c))) + .collect(); + let bytes = bytes.ok_or(KEYWORD_REPERTOIRE)?; + if bytes.is_empty() || bytes.len() > 79 { + return Err(KEYWORD_LENGTH); + } + if keyword.starts_with(' ') || keyword.ends_with(' ') || keyword.contains(" ") { + return Err(KEYWORD_SPACES); + } + Ok(bytes) +} + +/// The Latin-1 bytes of a `tEXt`/`zTXt` text string, or `None` when a character is outside +/// [`text_repertoire`] — the signal to promote the annotation to `iTXt`. +fn text_bytes(text: &str) -> Option> { + text.chars() + .map(|c| latin1_byte(c).filter(|_| text_repertoire(c))) + .collect() +} + +/// The §11.3.3.4 clause an `iTXt`'s language tag or translated keyword breaks, if any. +fn itxt_field_fault(language: &str, translated: &str) -> Option<&'static str> { + if !language + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-') + { + return Some(LANGUAGE_TAG); + } + translated.contains('\0').then_some(TRANSLATED_NUL) } /// Accumulated ancillary metadata to emit alongside the image. @@ -170,13 +287,10 @@ pub(crate) struct Ancillary { pub c2pa: Option>, /// tEXt / zTXt / iTXt entries, emitted in insertion order. texts: Vec, - /// Whether a caller set a text annotation whose **keyword** has no Latin-1 encoding. - /// - /// §11.3.3.1 restricts a keyword to Latin-1 in all three text chunks, so — unlike the text, - /// which `iTXt` carries in UTF-8 — there is no chunk such a keyword fits. The entry is - /// dropped at the setter and the encode is refused by [`Self::validate`], rather than - /// silently writing a keyword no reader can match. - unencodable_keyword: bool, + /// Whether the entries being pushed right now come from a metadata carry, so that a second + /// carry can replace exactly what the first contributed. Set between [`Self::begin_carry`] + /// and [`Self::end_carry`]. + carrying: bool, } impl Ancillary { @@ -206,93 +320,147 @@ impl Ancillary { } /// Adds an `iTXt` entry keeping its language tag and translated keyword (§11.3.3.4), which - /// [`add_text_international`](Self::add_text_international) leaves empty. Used only to carry - /// a decoded annotation forward, so that re-encoding a file does not silently drop the two - /// fields that make `iTXt` international. + /// [`add_text_international`](Self::add_text_international) leaves empty, and its compression + /// flag. Used to carry a decoded annotation forward without changing its identity: neither + /// the two fields that make `iTXt` international nor the flag that keeps a 40-byte payload + /// from being rewritten as 1600 uncompressed bytes. pub(crate) fn add_text_international_tagged( &mut self, keyword: &str, language: &str, translated: &str, text: &str, + compressed: bool, ) { - if let Some(mut entry) = self.text_entry(keyword, text, TextKind::International) { - entry.language = language.as_bytes().to_vec(); - entry.translated = translated.as_bytes().to_vec(); - self.texts.push(entry); + let kind = if compressed { + TextKind::InternationalCompressed + } else { + TextKind::International + }; + let mut entry = self.text_entry(keyword, text, kind); + if entry.fault.is_none() { + entry.fault = itxt_field_fault(language, translated).map(|reason| TextFault { + keyword: keyword.to_string(), + reason, + }); } + entry.language = language.as_bytes().to_vec(); + entry.translated = translated.as_bytes().to_vec(); + self.texts.push(entry); } - fn push_text(&mut self, keyword: &str, text: &str, kind: TextKind) { - if let Some(entry) = self.text_entry(keyword, text, kind) { - self.texts.push(entry); + /// Adds an XMP packet as the `iTXt` §11.3.3.1 Table 21 reserves for it. + /// + /// Takes bytes rather than a `&str` because that is what the read side surfaces: a file's + /// packet is whatever bytes its chunk held. §11.3.3.4 gives the `iTXt` text field UTF-8 and + /// no alternative, so bytes that are not UTF-8 have no chunk to go in — and are recorded as + /// a refusal rather than discarded, because a caller that handed this encoder a packet is + /// entitled to learn it did not come out the other side. + pub(crate) fn add_xmp(&mut self, packet: &[u8]) { + match str::from_utf8(packet) { + Ok(text) => self.add_text_international(XMP_KEYWORD, text), + Err(_) => { + let mut entry = self.text_entry(XMP_KEYWORD, "", TextKind::International); + entry.fault = Some(TextFault { + keyword: XMP_KEYWORD.to_string(), + reason: XMP_NOT_UTF8, + }); + self.texts.push(entry); + } } } - /// Builds the entry for one text annotation, choosing the chunk that can actually carry it. + /// Starts carrying a read file's metadata, discarding whatever a previous carry contributed. + /// + /// This is what makes [`PngEncoder::with_metadata`](crate::PngEncoder::with_metadata) + /// idempotent for text. The single-value slots — `gamma`, `iccp`, `srgb`, … — are idempotent + /// already because a second write overwrites the first; the text list is the one place where + /// "set it again" would otherwise mean "append it again", duplicating every annotation. + pub(crate) fn begin_carry(&mut self) { + self.texts.retain(|entry| !entry.carried); + self.carrying = true; + } + + /// Ends the carry started by [`begin_carry`](Self::begin_carry), so later direct setters push + /// entries a subsequent carry will not remove. + pub(crate) fn end_carry(&mut self) { + self.carrying = false; + } + + fn push_text(&mut self, keyword: &str, text: &str, kind: TextKind) { + let entry = self.text_entry(keyword, text, kind); + self.texts.push(entry); + } + + /// Builds the entry for one text annotation, choosing the chunk that can actually carry it + /// and recording the clause it breaks if no chunk can. /// /// The caller's `kind` is a *preference*, not a guarantee: §11.3.3.2 says outright that "text /// containing characters outside the repertoire of ISO/IEC 8859-1 should be encoded using the - /// `iTXt` chunk", so a `tEXt`/`zTXt` request whose text is not Latin-1 is promoted to `iTXt` - /// rather than written as UTF-8 bytes a Latin-1 reader mis-renders. The promotion keeps the - /// caller's *other* choice — compression — because §11.3.3.4 gives `iTXt` a compression flag - /// of its own; only the character set changes. + /// `iTXt` chunk", so a `tEXt`/`zTXt` request whose text leaves [`text_repertoire`] is + /// promoted rather than written as bytes a Latin-1 reader mis-renders. The promotion keeps + /// the caller's *other* choice, compression, because §11.3.3.4 gives `iTXt` a flag of its own. /// - /// `None` (the entry is dropped, and [`Self::validate`] then refuses the encode) is reserved - /// for the one case no chunk can express: a keyword outside Latin-1. - fn text_entry(&mut self, keyword: &str, text: &str, kind: TextKind) -> Option { - let Some(keyword) = latin1_bytes(keyword) else { - self.unencodable_keyword = true; - return None; + /// A null in the text is the one thing promotion cannot fix — §11.3.3.2 and §11.3.3.4 both + /// forbid it, and it is the field separator, so the chunk would re-parse as a different + /// annotation — and neither can a keyword outside §11.3.3.1's repertoire, length or spacing + /// rules. Those become a [`TextFault`] the entry carries to [`Self::validate`]. + fn text_entry(&self, keyword: &str, text: &str, kind: TextKind) -> TextEntry { + let (keyword_bytes, keyword_fault) = match keyword_bytes(keyword) { + Ok(bytes) => (bytes, None), + Err(reason) => (Vec::new(), Some(reason)), }; - let (kind, text) = match (kind, latin1_bytes(text)) { - (TextKind::Latin1, Some(latin1)) => (TextKind::Latin1, latin1), - (TextKind::Compressed, Some(latin1)) => (TextKind::Compressed, latin1), - (TextKind::Latin1, None) => (TextKind::International, text.as_bytes().to_vec()), - (TextKind::Compressed, None) => { - (TextKind::InternationalCompressed, text.as_bytes().to_vec()) - } - (kind, _) => (kind, text.as_bytes().to_vec()), + let reason = keyword_fault.or_else(|| text.contains('\0').then_some(TEXT_NUL)); + // An iTXt was asked for as UTF-8 and stays UTF-8; only a Latin-1 request has a + // repertoire to leave. + let latin1 = match kind { + TextKind::Latin1 | TextKind::Compressed => text_bytes(text), + TextKind::International | TextKind::InternationalCompressed => None, + }; + let (kind, text_bytes) = match latin1 { + Some(bytes) => (kind, bytes), + None => (kind.international(), text.as_bytes().to_vec()), }; - Some(TextEntry { - keyword, - text, + TextEntry { + keyword: keyword_bytes, + text: text_bytes, language: Vec::new(), translated: Vec::new(), kind, - }) + carried: self.carrying, + fault: reason.map(|reason| TextFault { + keyword: keyword.to_string(), + reason, + }), + } } - /// Refuses an accumulation the spec says must not be written, before any byte is emitted. + /// Refuses an accumulation the spec forbids, before any byte is emitted. /// - /// Two cases, both of which the caller stated explicitly and neither of which this encoder - /// may silently resolve for it: + /// Only the text chunks are refusable here, and only where a clause is a requirement rather + /// than a recommendation: a keyword outside §11.3.3.1's repertoire, length or spacing rules; + /// a null in a text string (§11.3.3.2, §11.3.3.4); a language tag or translated keyword + /// §11.3.3.4 rules out; a non-UTF-8 XMP packet. Each is a chunk that would be *read back as + /// something else* — the null re-frames the annotation outright — so writing it is a silent + /// corruption, and dropping it is a silent loss. /// - /// - **`sRGB` together with `iCCP`.** §5.6 Table 5 records the constraint on both rows — "if - /// the `iCCP` chunk is present, the `sRGB` chunk should not be present" and its converse — - /// and §11.3.2.5 repeats it ("it is recommended that the `sRGB` and `iCCP` chunks do not - /// appear simultaneously in a PNG datastream"). Emitting both is not undefined, because - /// §4.3 Table 1 ranks the colour chunks and a reader takes the lowest priority number - /// (`iCCP` 2 over `sRGB` 3) — but it *is* a datastream the standard tells encoders not to - /// produce, and which of the two the caller meant is not something this crate can guess. - /// Dropping one silently would lose colour information the caller supplied, so the encode - /// is refused. To carry both forward from a decoded file, use - /// [`PngEncoder::with_metadata`](crate::PngEncoder::with_metadata), which applies Table 1 - /// itself. - /// - **A text keyword outside Latin-1** (§11.3.3.1), which no text chunk can carry. + /// The colour chunks are deliberately **not** policed. §5.6 Table 5 and §11.3.2.5 say only + /// that `sRGB` and `iCCP` "should not" appear together, and §15 gives the BCP 14 keywords + /// force "when, and only when, they appear in all capitals"; §4.3 Table 1 then *presupposes* + /// the co-occurrence and defines the outcome by ranking the chunks. Both are written, and a + /// reader takes the highest-priority one. pub(crate) fn validate(&self) -> Result<()> { - if self.srgb.is_some() && self.iccp.is_some() { - return Err(Error::invalid_input( - env!("CARGO_PKG_NAME"), - "PNG: sRGB and iCCP must not both be written (spec §5.6 Table 5, §11.3.2.5); \ - set one", - )); - } - if self.unencodable_keyword { - return Err(Error::invalid_input( - env!("CARGO_PKG_NAME"), - "PNG: a text keyword must be Latin-1 (spec §11.3.3.1)", - )); + for (index, entry) in self.texts.iter().enumerate() { + if let Some(fault) = &entry.fault { + return Err(Error::invalid_input( + env!("CARGO_PKG_NAME"), + "PNG: a text annotation breaks the clause of the chunk that would carry it", + ) + .with_detail(format!( + "text annotation {index} (keyword {:?}): {}", + fault.keyword, fault.reason + ))); + } } Ok(()) } @@ -969,21 +1137,32 @@ mod tests { assert_eq!(find_chunk(&post, b"bKGD"), None); } + /// Encodes `a`'s post-PLTE chunks and returns the buffer, so a claim can read the bytes a + /// text annotation actually becomes. + fn post_plte(a: &Ancillary) -> Vec { + let mut out = vec![0u8; 8]; + a.write_post_plte(&mut out, DeflateEncoder::DEFAULT_EFFORT, RGB8); + out + } + + /// The refusal `validate` gives, rendered — including the owned detail naming the annotation. + fn refusal(a: &Ancillary) -> String { + a.validate().expect_err("the encode is refused").to_string() + } + /// A `tEXt` text string "is interpreted according to the Latin-1 character set" (§11.3.3.2), /// so a character above U+007F is **one** byte, not its UTF-8 pair. /// - /// Kills a mutant of [`Ancillary::text_entry`] that keeps the caller's `String` bytes: `é` - /// would be stored as `C3 A9`, which a conforming reader renders `é`. Asserted on the chunk - /// payload rather than through a decode, because this crate's decoder maps Latin-1 back + /// Kills a mutant of [`text_bytes`] that keeps the caller's `String` bytes: `é` would be + /// stored as `C3 A9`, which a conforming reader renders `é`. Asserted on the chunk payload + /// rather than through a decode, because this crate's decoder maps Latin-1 back /// code-point-for-code-point and would agree with the encoder either way. #[test] fn latin1_text_is_written_one_byte_per_character() { let mut a = Ancillary::default(); a.add_text_latin1("Author", "café ÿ"); - let mut out = vec![0u8; 8]; - a.write_post_plte(&mut out, DeflateEncoder::DEFAULT_EFFORT, RGB8); assert_eq!( - find_chunk(&out, b"tEXt"), + find_chunk(&post_plte(&a), b"tEXt"), Some(b"Author\0caf\xE9 \xFF".to_vec()) ); } @@ -992,14 +1171,13 @@ mod tests { /// encoded using the iTXt chunk." A `tEXt` request whose text has no Latin-1 encoding is /// therefore promoted rather than mangled or dropped. /// - /// Kills the `(TextKind::Latin1, None)` arm of [`Ancillary::text_entry`]. The keyword stays - /// Latin-1 either way (§11.3.3.1 binds it in every text chunk). + /// Kills the `None` arm of [`Ancillary::text_entry`]'s promotion. The keyword stays Latin-1 + /// either way (§11.3.3.1 binds it in every text chunk). #[test] fn text_outside_latin1_is_promoted_to_itxt() { let mut a = Ancillary::default(); a.add_text_latin1("Title", "字"); - let mut out = vec![0u8; 8]; - a.write_post_plte(&mut out, DeflateEncoder::DEFAULT_EFFORT, RGB8); + let out = post_plte(&a); assert_eq!(find_chunk(&out, b"tEXt"), None); // keyword, NUL, compression flag 0, method 0, empty language, empty translated keyword, // then the UTF-8 text (§11.3.3.4). @@ -1009,19 +1187,55 @@ mod tests { ); } + /// §11.3.3.1 restricts a `tEXt`/`zTXt` text string to "the printable Latin-1 character set + /// plus U+000A LINE FEED (LF)", and a control character is outside it — so it promotes, for + /// the same reason a Han character does. The character survives either way; only the chunk + /// that can define it changes. + /// + /// Kills [`text_repertoire`] mutated to accept everything Latin-1 can hold, which the looser + /// wording of §11.3.3.2 ("may contain any Latin-1 character") would otherwise excuse. 0x7F + /// DELETE is Latin-1-encodable and still not printable. + #[test] + fn a_control_character_promotes_the_annotation_to_itxt() { + let mut a = Ancillary::default(); + a.add_text_latin1("Title", "one\u{7F}two"); + let out = post_plte(&a); + assert_eq!(find_chunk(&out, b"tEXt"), None); + assert_eq!( + find_chunk(&out, b"iTXt"), + Some(b"Title\0\0\0\0\0one\x7Ftwo".to_vec()) + ); + } + + /// The other side of the same boundary: a line feed and the top of Latin-1 are *inside* the + /// repertoire §11.3.3.1 grants `tEXt`, so neither promotes. + /// + /// Kills [`text_repertoire`] mutated to drop its `'\n'` case or to stop at 0xFE, either of + /// which would push an ordinary multi-line Latin-1 note into an `iTXt`. + #[test] + fn a_line_feed_and_the_top_of_latin1_stay_in_a_text_chunk() { + let mut a = Ancillary::default(); + a.add_text_latin1("Description", "line\nÿ"); + let out = post_plte(&a); + assert_eq!(find_chunk(&out, b"iTXt"), None); + assert_eq!( + find_chunk(&out, b"tEXt"), + Some(b"Description\0line\n\xFF".to_vec()) + ); + } + /// Promoting a `zTXt` keeps the caller's *compression*, because §11.3.3.4 gives `iTXt` a /// compression flag of its own — only the character set had to change. /// - /// Kills the `(TextKind::Compressed, None)` arm of [`Ancillary::text_entry`] and the - /// compression-flag byte in [`write_text`]: a mutant that promotes to plain `International` - /// leaves the flag at 0 and the body uncompressed. + /// Kills the `Compressed` arm of [`TextKind::international`] and the compression-flag byte in + /// [`write_text`]: a mutant that promotes to plain `International` leaves the flag at 0 and + /// the body uncompressed. #[test] fn compressed_text_outside_latin1_stays_compressed_in_itxt() { let body = "字".repeat(200); let mut a = Ancillary::default(); a.add_text_compressed("Comment", &body); - let mut out = vec![0u8; 8]; - a.write_post_plte(&mut out, DeflateEncoder::DEFAULT_EFFORT, RGB8); + let out = post_plte(&a); assert_eq!(find_chunk(&out, b"zTXt"), None); let itxt = find_chunk(&out, b"iTXt").expect("promoted to iTXt"); assert_eq!(&itxt[..12], b"Comment\0\x01\0\0\0"); @@ -1032,49 +1246,190 @@ mod tests { ); } - /// §5.6 Table 5 states it on both rows — "If the iCCP chunk is present, the sRGB chunk should - /// not be present" and its converse — and §11.3.2.5 repeats it. Setting both is a question - /// only the caller can answer, so the encode is refused rather than one chunk silently - /// dropped. + /// §5.6 Table 5 and §11.3.2.5 say only that the two chunks "should not" appear together — + /// lowercase, and §15 gives the BCP 14 keywords force "when, and only when, they appear in + /// all capitals" — while §4.3 Table 1 presupposes the pair and ranks it. Both are written, so + /// no colour information the caller supplied is thrown away. /// - /// Kills the first guard of [`Ancillary::validate`]. Asserts the message, not `is_err`: the - /// second guard also rejects, so `is_err` alone would survive removing this one. + /// Kills a mutant that reinstates a refusal or drops one of the two chunks. #[test] - fn srgb_beside_iccp_is_refused() { + fn a_profile_and_a_rendering_intent_are_both_written() { let mut a = Ancillary::default(); a.set_srgb(SrgbIntent::Perceptual); - assert!(a.validate().is_ok(), "sRGB alone is fine"); - a.iccp = Some(("prof".to_string(), vec![0u8; 4])); - let error = a.validate().expect_err("sRGB beside iCCP"); + assert!(a.validate().is_ok(), "the pair is legal"); + + let mut out = vec![0u8; 8]; + a.write_pre_plte(&mut out, DeflateEncoder::DEFAULT_EFFORT, RGB8); + assert_eq!(find_chunk(&out, b"sRGB"), Some(vec![0])); assert!( - error.to_string().contains("sRGB and iCCP must not both"), - "{error}" + find_chunk(&out, b"iCCP").is_some(), + "the profile is written" ); + } + + /// §11.3.3.1: "Keywords are restricted to 1 to 79 bytes in length." Both edges, because an + /// empty keyword makes a third-party reader drop the whole annotation and an over-long one is + /// a chunk no conforming reader has to accept. + /// + /// Kills the length guard in [`keyword_bytes`], including a mutant that shifts either bound + /// by one. + #[test] + fn a_keyword_outside_one_to_seventy_nine_bytes_is_refused() { + let mut ok = Ancillary::default(); + ok.add_text_latin1(&"k".repeat(79), "body"); + ok.add_text_latin1("k", "body"); + assert!(ok.validate().is_ok(), "79 bytes and 1 byte are inside"); + + for keyword in ["", &"k".repeat(80)] { + let mut a = Ancillary::default(); + a.add_text_latin1(keyword, "body"); + assert!( + refusal(&a).contains("restricted to 1 to 79 bytes"), + "keyword of {} bytes", + keyword.len() + ); + } + } + + /// §11.3.3.1: "only code points 0x20-7E and 0xA1-FF are allowed", and expressly "nor is + /// U+00A0 NON-BREAKING SPACE since it is visually indistinguishable from an ordinary space". + /// The null is the same clause read through §11.3.3.2 — and the one that *corrupts* rather + /// than merely offends, because it is the field separator: `Auth\0or` re-parses as the + /// annotation `Auth`. + /// + /// Kills the repertoire guard in [`keyword_bytes`] and each edge of [`printable_latin1`]. + #[test] + fn a_keyword_outside_the_printable_latin1_repertoire_is_refused() { + for keyword in [ + "Auth\0or", // the field separator itself + "Auth\u{7F}", // DELETE + "Auth\u{9F}", // C1 control + "Auth\u{A0}", // NON-BREAKING SPACE, named by the clause + "题", // outside Latin-1 altogether + ] { + let mut a = Ancillary::default(); + a.add_text_latin1(keyword, "body"); + assert!( + refusal(&a).contains("code points 0x20-0x7E and 0xA1-0xFF"), + "keyword {keyword:?}" + ); + } - a.srgb = None; - assert!(a.validate().is_ok(), "iCCP alone is fine"); + let mut edges = Ancillary::default(); + edges.add_text_latin1("a\u{20}b\u{7E}\u{A1}\u{FF}", "body"); + assert!(edges.validate().is_ok(), "0x20, 0x7E, 0xA1 and 0xFF are in"); } - /// §11.3.3.1 binds the keyword to Latin-1 in all three text chunks, so — unlike the text, - /// which §11.3.3.2 routes to `iTXt` — a keyword outside it has no chunk at all. The entry is - /// not written, and the encode is refused rather than the annotation quietly disappearing. + /// §11.3.3.1: "leading spaces, trailing spaces, and consecutive spaces are not permitted in + /// keywords", so that a keyword cannot be misread as another. /// - /// Kills the keyword arm of [`Ancillary::text_entry`] and the second guard of - /// [`Ancillary::validate`]. Asserts the message for the same reason as the sRGB test. + /// Kills the spacing guard in [`keyword_bytes`], one condition at a time. #[test] - fn a_text_keyword_outside_latin1_is_refused() { + fn a_keyword_with_a_leading_trailing_or_consecutive_space_is_refused() { + for keyword in [" Author", "Author ", "Two Words"] { + let mut a = Ancillary::default(); + a.add_text_latin1(keyword, "body"); + assert!( + refusal(&a).contains("leading, trailing or consecutive space"), + "keyword {keyword:?}" + ); + } + + let mut ok = Ancillary::default(); + ok.add_text_latin1("Two Words", "body"); + assert!(ok.validate().is_ok(), "a single interior space is allowed"); + } + + /// §11.3.3.2: "Neither the keyword nor the text string may contain a null character", and + /// §11.3.3.4 the same for `iTXt`. This is corruption, not pedantry: the null is the field + /// separator, so `note\0Author\0other` written as a `tEXt` body re-parses as a *different* + /// annotation. Promotion cannot rescue it, because `iTXt` forbids it too. + /// + /// Kills the null guard in [`Ancillary::text_entry`], in both the Latin-1 and the UTF-8 + /// request — a mutant that checks only one leaves the other writing the corrupt chunk. + #[test] + fn a_null_in_a_text_string_is_refused() { + let mut latin1 = Ancillary::default(); + latin1.add_text_latin1("Note", "before\0after"); + assert!(refusal(&latin1).contains("may not contain a null character")); + + let mut utf8 = Ancillary::default(); + utf8.add_text_international("Note", "before\0after"); + assert!(refusal(&utf8).contains("may not contain a null character")); + } + + /// §11.3.3.4: "The translated keyword and text both use the UTF-8 encoding, and neither shall + /// contain a zero byte (null character)" — the translated keyword is null-terminated too, so + /// an embedded null re-frames everything after it. + /// + /// Kills the translated-keyword arm of [`itxt_field_fault`]. + #[test] + fn a_null_in_a_translated_keyword_is_refused() { let mut a = Ancillary::default(); - a.add_text_latin1("题", "body"); - assert!(a.texts.is_empty(), "the entry is not written"); + a.add_text_international_tagged("Note", "de", "No\0tiz", "body", false); + assert!(refusal(&a).contains("translated keyword may not contain a null")); + } + + /// §11.3.3.4: "The language tag is a well-formed language tag defined by [BCP47]", whose + /// subtags are ASCII letters and digits joined by hyphens. Anything else is not a tag, and — + /// written as UTF-8 into a field a reader takes as Latin-1 — would not even survive the trip. + /// + /// Kills the language arm of [`itxt_field_fault`], and the empty case pins that "unspecified" + /// stays legal. + #[test] + fn a_language_tag_outside_bcp_47_is_refused() { + for language in ["de\0DE", "zh_Hans", "dé"] { + let mut a = Ancillary::default(); + a.add_text_international_tagged("Note", language, "", "body", false); + assert!( + refusal(&a).contains("ASCII letters, digits and '-'"), + "language {language:?}" + ); + } - let error = a.validate().expect_err("keyword outside Latin-1"); + let mut ok = Ancillary::default(); + ok.add_text_international_tagged("Note", "", "", "body", false); + ok.add_text_international_tagged("Note", "ar-AE-u-nu-latn", "", "body", false); assert!( - error.to_string().contains("keyword must be Latin-1"), - "{error}" + ok.validate().is_ok(), + "empty and a full BCP 47 tag are fine" ); } + /// §11.3.3.4 gives the `iTXt` text field UTF-8 and no alternative, so a packet that is not + /// UTF-8 has no chunk to go in. It is refused rather than quietly discarded: the read side + /// surfaces a packet as raw bytes, and a caller that handed those bytes back is entitled to + /// learn they did not come out the other side. + /// + /// Kills the `Err` arm of [`Ancillary::add_xmp`] — with it gone the packet vanishes silently. + #[test] + fn a_non_utf8_xmp_packet_is_refused() { + let mut a = Ancillary::default(); + a.add_xmp(b""); + assert!(refusal(&a).contains("XMP packet is not UTF-8")); + + let mut valid = Ancillary::default(); + valid.add_xmp(b""); + assert!(valid.validate().is_ok(), "a UTF-8 packet is carried"); + assert!(find_chunk(&post_plte(&valid), b"iTXt").is_some()); + } + + /// A refusal a caller cannot act on is barely better than a silent drop, so it names *which* + /// annotation offended — its position and its keyword, escaped so a null shows up. + /// + /// Kills the `enumerate` and the owned detail in [`Ancillary::validate`]: with either gone + /// the message is the same for every annotation in the file. + #[test] + fn the_refusal_names_the_annotation_and_its_keyword() { + let mut a = Ancillary::default(); + a.add_text_latin1("Title", "fine"); + a.add_text_latin1("Author", "bad\0body"); + let message = refusal(&a); + assert!(message.contains("text annotation 1"), "{message}"); + assert!(message.contains(r#""Author""#), "{message}"); + } + /// §11.3.3.4's language tag and translated keyword survive, so carrying a decoded `iTXt` /// forward does not strip the two fields that make it international. /// @@ -1083,15 +1438,31 @@ mod tests { #[test] fn a_tagged_itxt_keeps_its_language_and_translated_keyword() { let mut a = Ancillary::default(); - a.add_text_international_tagged("Author", "de", "Autor", "gämut"); - let mut out = vec![0u8; 8]; - a.write_post_plte(&mut out, DeflateEncoder::DEFAULT_EFFORT, RGB8); + a.add_text_international_tagged("Author", "de", "Autor", "gämut", false); assert_eq!( - find_chunk(&out, b"iTXt"), + find_chunk(&post_plte(&a), b"iTXt"), Some(b"Author\0\0\0de\0Autor\0g\xC3\xA4mut".to_vec()) ); } + /// A carry replaces what an earlier carry contributed instead of appending a second copy, so + /// `with_metadata` is idempotent for text the way the single-value colour slots already are. + /// + /// Kills the `retain` in [`Ancillary::begin_carry`] (two copies of every annotation) and the + /// `carried` flag's `end_carry` reset (a carry that also eats the caller's own annotations). + #[test] + fn a_second_carry_replaces_the_first_and_spares_direct_setters() { + let mut a = Ancillary::default(); + a.add_text_latin1("Mine", "kept"); + for _ in 0..2 { + a.begin_carry(); + a.add_text_latin1("Carried", "once"); + a.end_carry(); + } + let keywords: Vec<&[u8]> = a.texts.iter().map(|e| e.keyword.as_slice()).collect(); + assert_eq!(keywords, [b"Mine".as_slice(), b"Carried".as_slice()]); + } + /// §11.3.2.6 Table 18 orders the payload primaries, transfer function, matrix coefficients, /// full-range flag — and fixes the matrix at 0 for PNG, so the setter has no argument for it. /// diff --git a/crates/gamut-png/src/decoded.rs b/crates/gamut-png/src/decoded.rs index 0b41153a..06551367 100644 --- a/crates/gamut-png/src/decoded.rs +++ b/crates/gamut-png/src/decoded.rs @@ -103,11 +103,34 @@ pub struct Cicp { pub full_range: bool, } +/// Which of §11.3.3's three chunks carried an annotation, and whether its text was compressed. +/// +/// The four combinations are the whole space PNG defines, so this enum is closed. It exists so a +/// re-encode can put an annotation back in the chunk it came out of: without it a `zTXt` is +/// indistinguishable from a `tEXt` once decoded, and rewriting a compressed 40-byte payload as an +/// uncompressed one can inflate it fortyfold — preservation that does not preserve. +/// +/// `#[repr(u8)]` with explicit, permanent discriminants: the value crosses the C ABI as a plain +/// integer. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u8)] +pub enum TextChunkKind { + /// `tEXt`: uncompressed Latin-1 (§11.3.3.2). + Text = 0, + /// `zTXt`: zlib-compressed Latin-1 (§11.3.3.3). + CompressedText = 1, + /// `iTXt` with the compression flag clear: uncompressed UTF-8 (§11.3.3.4). + International = 2, + /// `iTXt` with the compression flag set: zlib-compressed UTF-8 (§11.3.3.4). + CompressedInternational = 3, +} + /// One text annotation (tEXt/zTXt/iTXt, §11.3.3), decompressed where stored compressed. /// /// tEXt/zTXt hold Latin-1, mapped code-point-for-code-point into the `String` (lossless); -/// iTXt holds UTF-8. The XMP packet (`XML:com.adobe.xmp`) is surfaced as [`DecodedPng::xmp`], -/// not repeated here. +/// iTXt holds UTF-8. [`kind`](Self::kind) records which chunk it was, so a re-encode can put it +/// back in the same one. The XMP packet (`XML:com.adobe.xmp`) is surfaced as +/// [`DecodedPng::xmp`], not repeated here. #[derive(Debug, Clone, PartialEq, Eq)] #[non_exhaustive] pub struct TextChunk { @@ -119,6 +142,8 @@ pub struct TextChunk { pub language: Option, /// The iTXt translated keyword, if the chunk carried one. pub translated_keyword: Option, + /// The chunk this annotation was stored in, and whether its text was compressed. + pub kind: TextChunkKind, } /// Everything a PNG carries: the pixels in their native layout plus the ancillary payloads. @@ -340,8 +365,9 @@ pub(crate) fn collect(chunks: &[([u8; 4], &[u8])], budget: usize) -> PngMetadata meta } -/// The standard iTXt keyword carrying an XMP packet (XMP Specification Part 3). -const XMP_KEYWORD: &str = "XML:com.adobe.xmp"; +/// The standard iTXt keyword carrying an XMP packet (XMP Specification Part 3), reserved for it +/// by §11.3.3.1 Table 21. Shared with the encoder so the two sides cannot disagree on it. +pub(crate) const XMP_KEYWORD: &str = "XML:com.adobe.xmp"; /// A parsed iTXt: either the XMP packet or an ordinary text annotation. enum ITxt { @@ -403,7 +429,7 @@ fn parse_chrm(data: &[u8]) -> Option { }) } -/// tEXt (§11.3.3.3): keyword, NUL, Latin-1 text. +/// tEXt (§11.3.3.2): keyword, NUL, Latin-1 text. fn parse_text(data: &[u8]) -> Option { let (keyword, text) = split_keyword(data)?; Some(TextChunk { @@ -411,10 +437,11 @@ fn parse_text(data: &[u8]) -> Option { text: latin1(text), language: None, translated_keyword: None, + kind: TextChunkKind::Text, }) } -/// zTXt (§11.3.3.4): keyword, NUL, compression method 0, deflated Latin-1 text. +/// zTXt (§11.3.3.3): keyword, NUL, compression method 0, deflated Latin-1 text. fn parse_ztxt(data: &[u8], budget: &mut usize) -> Option { let (keyword, rest) = split_keyword(data)?; let (&method, compressed) = rest.split_first()?; @@ -427,10 +454,11 @@ fn parse_ztxt(data: &[u8], budget: &mut usize) -> Option { text: latin1(&text), language: None, translated_keyword: None, + kind: TextChunkKind::CompressedText, }) } -/// iTXt (§11.3.3.5): keyword, NUL, compression flag, compression method, language tag, NUL, +/// iTXt (§11.3.3.4): keyword, NUL, compression flag, compression method, language tag, NUL, /// translated keyword, NUL, UTF-8 text (deflated when the flag is 1). fn parse_itxt(data: &[u8], budget: &mut usize) -> Option { let (keyword, rest) = split_keyword(data)?; @@ -454,6 +482,11 @@ fn parse_itxt(data: &[u8], budget: &mut usize) -> Option { text: String::from_utf8(text_bytes).ok()?, language: Some(language).filter(|l| !l.is_empty()), translated_keyword: Some(translated).filter(|t| !t.is_empty()), + kind: if flag == 1 { + TextChunkKind::CompressedInternational + } else { + TextChunkKind::International + }, })) } diff --git a/crates/gamut-png/src/decoder.rs b/crates/gamut-png/src/decoder.rs index 562b4641..cc7cb071 100644 --- a/crates/gamut-png/src/decoder.rs +++ b/crates/gamut-png/src/decoder.rs @@ -1636,6 +1636,7 @@ mod tests { #[test] fn rich_decode_surfaces_metadata_and_native_image() { + use crate::SrgbIntent; use crate::decoded::PngImage; let (w, h) = (6u32, 4u32); @@ -1646,8 +1647,7 @@ mod tests { let mut png = Vec::new(); PngEncoder::new() .with_gamma(1.0 / 2.2) - // cICP, not sRGB: the encoder refuses sRGB beside the iCCP this fixture needs - // (§5.6 Table 5, §11.3.2.5), while cICP is legal alongside it (§4.3 Table 1). + .with_srgb(SrgbIntent::Perceptual) .with_cicp(9, 16, true) .with_chromaticities((0.3127, 0.3290), (0.64, 0.33), (0.30, 0.60), (0.15, 0.06)) .with_exif(&exif) @@ -1671,7 +1671,7 @@ mod tests { other => panic!("expected Rgb8, got {other:?}"), } assert_eq!(decoded.gamma, Some(45455)); - assert!(decoded.srgb.is_none()); + assert_eq!(decoded.srgb, Some(SrgbIntent::Perceptual)); let chrm = decoded.chromaticities.unwrap(); assert_eq!(chrm.white, (31270, 32900)); assert_eq!(chrm.blue, (15000, 6000)); diff --git a/crates/gamut-png/src/encoder.rs b/crates/gamut-png/src/encoder.rs index fd261996..716b839b 100644 --- a/crates/gamut-png/src/encoder.rs +++ b/crates/gamut-png/src/encoder.rs @@ -31,7 +31,9 @@ use crate::ancillary::{ use crate::backend::{IdatDeflater, IdatInfo, Registry, run_deflaters}; use crate::chunk::{self, C2paSpan, SIGNATURE}; use crate::color::ColorType; -use crate::decoded::{Chromaticities, Cicp, DecodedPng, IccProfile, PngMetadata, TextChunk}; +use crate::decoded::{ + Chromaticities, Cicp, DecodedPng, IccProfile, PngMetadata, TextChunk, TextChunkKind, +}; use crate::filter::{self, FilterStrategy, FilterType}; use crate::palette::PngPalette; use crate::reduce::{self, Reduced, Reductions}; @@ -90,6 +92,56 @@ struct MetadataView<'a> { chromaticities: Option, srgb: Option, cicp: Option, + /// Whether the source carried a C2PA manifest store. Only the presence is needed: a store is + /// never carried, but a caller has to be told it was left behind. + c2pa: bool, +} + +/// A metadata payload [`PngEncoder::with_metadata`] could not carry into the output. +/// +/// Preservation exists to stop metadata disappearing quietly, so the two payloads a carry cannot +/// take are named rather than dropped in silence. Read them back with +/// [`PngEncoder::dropped_metadata`] and tell the user — `gamut convert` does. +/// +/// `#[repr(u8)]` with explicit discriminants, which are permanent and append-only: the value +/// crosses the C ABI as a plain integer. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u8)] +#[non_exhaustive] +pub enum DroppedMetadata { + /// A `cICP` whose matrix coefficients are not 0. §11.3.2.6 requires 0 for PNG — "RGB is + /// currently the only supported color model in PNG, and as such Matrix Coefficients shall be + /// set to 0" — so the source chunk is not conforming and copying it forward would reproduce + /// the defect in a file this encoder signed off on. + NonRgbCicp = 0, + /// The C2PA manifest store (`caBX`). A store is signed over the exact bytes of the file it + /// was made for, which is why C2PA 2.4 §A.3.2 marks the chunk unsafe to copy: carried into a + /// re-encode it is invalid by construction, and a validator reports a *tampered* file rather + /// than an unsigned one. Re-sign the output and set it with + /// [`with_c2pa`](PngEncoder::with_c2pa). + C2paManifestStore = 1, +} + +impl DroppedMetadata { + /// One line naming what was left behind and why, fit to show a user. + #[must_use] + pub fn reason(self) -> &'static str { + match self { + Self::NonRgbCicp => { + "cICP: its matrix coefficients are not 0, which PNG requires (§11.3.2.6)" + } + Self::C2paManifestStore => { + "C2PA manifest store: signed over the source bytes, so a copy would be invalid \ + (C2PA 2.4 §A.3.2) — re-sign the output" + } + } + } +} + +impl core::fmt::Display for DroppedMetadata { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str(self.reason()) + } } /// A reusable PNG encoder. @@ -102,6 +154,9 @@ pub struct PngEncoder { auto_reduce: bool, clean_transparent: bool, backends: Registry, + /// What the last metadata carry could not take, in the order it was found. Reset by each + /// [`Self::with_metadata`] / [`Self::with_metadata_from`] call, so it describes that call. + dropped: Vec, } impl Default for PngEncoder { @@ -123,6 +178,7 @@ impl PngEncoder { auto_reduce: false, clean_transparent: false, backends: Registry::default(), + dropped: Vec::new(), } } @@ -225,12 +281,14 @@ impl PngEncoder { self } - /// Records the standard colour-space rendering intent (sRGB chunk). + /// Records the standard colour-space rendering intent (sRGB chunk, §11.3.2.5). /// - /// Mutually exclusive with [`with_icc_profile`](Self::with_icc_profile): PNG §5.6 Table 5 and - /// §11.3.2.5 both say the two chunks should not appear together, so setting both makes the - /// encode fail with [`Error::InvalidInput`] rather than write a file the standard tells - /// encoders not to produce. [`with_metadata`](Self::with_metadata) resolves the pair for you. + /// May be combined with [`with_icc_profile`](Self::with_icc_profile). §5.6 Table 5 and + /// §11.3.2.5 say only that the two "should not" appear together — lowercase, and §15 gives + /// the BCP 14 keywords force "when, and only when, they appear in all capitals" — while §4.3 + /// Table 1 presupposes the pair and settles it, ranking `iCCP` (priority 2) above `sRGB` + /// (3). Both are written; a reader honours the profile and treats the intent as the fallback + /// for readers that cannot apply one. #[must_use] pub fn with_srgb(mut self, intent: SrgbIntent) -> Self { self.ancillary.set_srgb(intent); @@ -395,13 +453,11 @@ impl PngEncoder { self } - /// Embeds an ICC colour profile (iCCP chunk), zlib-compressed. `profile` is the raw ICC profile - /// — for example the bytes produced by `gamut-icc`. + /// Embeds an ICC colour profile (iCCP chunk, §11.3.2.3), zlib-compressed. `profile` is the + /// raw ICC profile — for example the bytes produced by `gamut-icc`. /// - /// Mutually exclusive with [`with_srgb`](Self::with_srgb): PNG §5.6 Table 5 and §11.3.2.5 both - /// say the two chunks should not appear together, so setting both makes the encode fail with - /// [`Error::InvalidInput`] rather than write a file the standard tells encoders not to - /// produce. [`with_metadata`](Self::with_metadata) resolves the pair for you. + /// May be combined with [`with_srgb`](Self::with_srgb); see there for why the pair is + /// written rather than refused, and which chunk a reader honours. #[must_use] pub fn with_icc_profile(mut self, name: &str, profile: &[u8]) -> Self { self.ancillary.iccp = Some((name.to_string(), profile.to_vec())); @@ -412,8 +468,7 @@ impl PngEncoder { /// the XMP/RDF document — for example the bytes produced by `gamut-xmp`. #[must_use] pub fn with_xmp(mut self, xmp: &str) -> Self { - self.ancillary - .add_text_international("XML:com.adobe.xmp", xmp); + self.ancillary.add_xmp(xmp.as_bytes()); self } @@ -426,26 +481,32 @@ impl PngEncoder { /// [`with_metadata_from`](Self::with_metadata_from) is the same thing for a full /// [`DecodedPng`]. /// + /// Calling it twice with the same metadata is the same as calling it once: a later carry + /// replaces what an earlier one contributed rather than appending a second copy of every + /// annotation. + /// /// # What it carries, and what it deliberately does not /// - /// Everything the read side surfaces is set, with three spec-driven adjustments: + /// Everything the read side surfaces is set, including a `cICP`, an `sRGB` and an `iCCP` + /// together — §4.3 Table 1 ranks the colour chunks precisely so a file may carry more than + /// one, and a reader honours the lowest priority number. Each text annotation goes back into + /// the chunk it came out of, compressed if it was compressed + /// ([`TextChunkKind`](crate::TextChunkKind)). + /// + /// Two payloads cannot be carried, and both are **named** rather than dropped in silence — + /// read them back with [`dropped_metadata`](Self::dropped_metadata): /// - /// - **`iCCP` and `sRGB` are resolved, not both written.** §4.3 Table 1 ranks the colour - /// chunks and a reader takes the lowest priority number, so the ICC profile (priority 2) - /// wins over the rendering intent (priority 3) and the `sRGB` chunk is dropped — which is - /// exactly the chunk a conforming reader would have ignored. Writing both is refused (§5.6 - /// Table 5, §11.3.2.5); this method is how a file carrying both is re-encoded at all. - /// - **A `cICP` whose matrix coefficients are not 0 is dropped.** §11.3.2.6 requires 0 for - /// PNG, so such a chunk is not conforming and copying it forward would reproduce the defect. - /// - **The C2PA manifest store is never carried.** A store is signed over the exact bytes of - /// the file it was made for, so copying it into a re-encode invalidates it by construction - /// — which is why `caBX` is *unsafe to copy* (C2PA 2.4 §A.3.2). Re-sign the output and set - /// it with [`with_c2pa`](Self::with_c2pa). + /// - a **`cICP` whose matrix coefficients are not 0**, which §11.3.2.6 does not allow in PNG; + /// - the **C2PA manifest store**, signed over the bytes of the file it was made for. /// - /// Two further limits are the read side's, not this method's: `pHYs`, `tIME`, `sBIT` and - /// `bKGD` are not part of [`PngMetadata`], so they cannot be carried here (set them with - /// their own builder methods); and a `zTXt` is indistinguishable from a `tEXt` once decoded, - /// so a compressed annotation is rewritten uncompressed. Neither loses any text. + /// Anything that would be *corrupted* rather than lost — a keyword outside §11.3.3.1's + /// repertoire, a null inside a text string, an XMP packet that is not UTF-8 — makes the + /// encode fail with [`Error::InvalidInput`] naming the annotation, rather than being written + /// as something a reader reads back differently. + /// + /// One further limit is the read side's, not this method's: `pHYs`, `tIME`, `sBIT` and `bKGD` + /// are not part of [`PngMetadata`], so they cannot be carried here (set them with their own + /// builder methods). #[must_use] pub fn with_metadata(self, metadata: &PngMetadata) -> Self { self.with_metadata_view(MetadataView { @@ -457,6 +518,7 @@ impl PngEncoder { chromaticities: metadata.chromaticities, srgb: metadata.srgb, cicp: metadata.cicp, + c2pa: metadata.c2pa.is_some(), }) } @@ -476,31 +538,58 @@ impl PngEncoder { chromaticities: decoded.chromaticities, srgb: decoded.srgb, cicp: decoded.cicp, + c2pa: decoded.c2pa.is_some(), }) } + /// What the last [`with_metadata`](Self::with_metadata) / + /// [`with_metadata_from`](Self::with_metadata_from) call could not carry, in the order it was + /// found — empty when it carried everything, and reset by each call. + /// + /// Surface this to whoever asked for the re-encode. Losing metadata without saying so is the + /// defect the preservation path exists to remove; losing it *with* an explanation is a + /// choice the spec forces. + #[must_use] + pub fn dropped_metadata(&self) -> &[DroppedMetadata] { + &self.dropped + } + /// The one implementation behind [`with_metadata`](Self::with_metadata) and /// [`with_metadata_from`](Self::with_metadata_from). fn with_metadata_view(mut self, meta: MetadataView<'_>) -> Self { + self.dropped.clear(); + self.ancillary.begin_carry(); if let Some(exif) = meta.exif { self = self.with_exif(exif); } - // §4.3 Table 1: the reader honours the lowest priority number, iCCP (2) over sRGB (3). - // Writing both is what `Ancillary::validate` refuses, so pick the one that would have - // been honoured rather than hand the caller an error it cannot act on. - match (meta.icc_profile, meta.srgb) { - (Some(icc), _) => self = self.with_icc_profile(&icc.name, &icc.profile), - (None, Some(intent)) => self = self.with_srgb(intent), - (None, None) => {} + // Both colour statements are carried. §5.6 Table 5 and §11.3.2.5 only *recommend* against + // the pair, and §4.3 Table 1 exists to resolve it: `iCCP` outranks `sRGB`, so the profile + // is what a reader applies and the intent is what a reader without a CMM falls back on. + // Dropping either would throw away colour information the source carried. + if let Some(icc) = meta.icc_profile { + self = self.with_icc_profile(&icc.name, &icc.profile); } - // §11.3.2.6: "Matrix Coefficients shall be set to 0". A source chunk that says otherwise - // is not a conforming cICP; carrying it forward would put the same defect in the output. - if let Some(cicp) = meta.cicp.filter(|cicp| cicp.matrix_coefficients == 0) { - self = self.with_cicp( - cicp.color_primaries, - cicp.transfer_function, - cicp.full_range, - ); + if let Some(intent) = meta.srgb { + self = self.with_srgb(intent); + } + match meta.cicp { + // §11.3.2.6: "Matrix Coefficients shall be set to 0". A source chunk that says + // otherwise is not a conforming cICP; carrying it forward would put the same defect + // in the output. + Some(cicp) if cicp.matrix_coefficients != 0 => { + self.dropped.push(DroppedMetadata::NonRgbCicp); + } + Some(cicp) => { + self = self.with_cicp( + cicp.color_primaries, + cicp.transfer_function, + cicp.full_range, + ); + } + None => {} + } + if meta.c2pa { + self.dropped.push(DroppedMetadata::C2paManifestStore); } // Set in the stored ×100 000 fixed-point units rather than through `with_gamma` / // `with_chromaticities`, whose `f64` arguments would round-trip the value through a @@ -520,25 +609,41 @@ impl PngEncoder { chrm.blue.1, ]); } - // The XMP packet is UTF-8 by §11.3.3.4; bytes that are not are not a packet this encoder - // can frame, and are dropped rather than written as an invalid iTXt. - if let Some(xmp) = meta.xmp.and_then(|bytes| str::from_utf8(bytes).ok()) { - self = self.with_xmp(xmp); + // Handed over as bytes, because that is what the chunk held. §11.3.3.4 requires UTF-8, so + // a packet that is not gets a refusal at `encode` naming it — never a silent drop. + if let Some(xmp) = meta.xmp { + self.ancillary.add_xmp(xmp); } for text in meta.texts { - match (&text.language, &text.translated_keyword) { - // Neither field set: the annotation came from a tEXt/zTXt, or from an iTXt whose - // two optional fields were empty. Offer it as Latin-1 — which is byte-exact for - // the first case — and let `Ancillary` promote it to iTXt if the text needs it. - (None, None) => self.ancillary.add_text_latin1(&text.keyword, &text.text), - (language, translated) => self.ancillary.add_text_international_tagged( + let (language, translated) = ( + text.language.as_deref().unwrap_or_default(), + text.translated_keyword.as_deref().unwrap_or_default(), + ); + match text.kind { + TextChunkKind::Text => self.ancillary.add_text_latin1(&text.keyword, &text.text), + TextChunkKind::CompressedText => { + self.ancillary + .add_text_compressed(&text.keyword, &text.text); + } + TextChunkKind::International => self.ancillary.add_text_international_tagged( &text.keyword, - language.as_deref().unwrap_or_default(), - translated.as_deref().unwrap_or_default(), + language, + translated, &text.text, + false, ), + TextChunkKind::CompressedInternational => { + self.ancillary.add_text_international_tagged( + &text.keyword, + language, + translated, + &text.text, + true, + ); + } } } + self.ancillary.end_carry(); self } diff --git a/crates/gamut-png/src/lib.rs b/crates/gamut-png/src/lib.rs index 9ddb73a7..ab7fdce5 100644 --- a/crates/gamut-png/src/lib.rs +++ b/crates/gamut-png/src/lib.rs @@ -91,13 +91,14 @@ pub use chunk::{C2paSpan, fill_c2pa}; pub use color::ColorType; pub use decoded::{ Chromaticities, Cicp, DecodedPng, IccProfile, PngHeader, PngImage, PngMetadata, TextChunk, + TextChunkKind, }; pub use decoder::{PngDecoder, TransparencyKey, metadata}; pub use deconstruct::{ ChunkStats, DEFAULT_MAX_CHUNKS, DeconstructLimits, FilterHistogram, FilterScan, PassStats, PngReport, Segment, SegmentKind, SkippedFilterScan, deconstruct, deconstruct_with_limits, }; -pub use encoder::{PngEncodeReport, PngEncoder}; +pub use encoder::{DroppedMetadata, PngEncodeReport, PngEncoder}; pub use filter::{FilterStrategy, FilterType}; /// The DEFLATE compression level, accepted by [`PngEncoder::with_compression`]. pub use gamut_deflate::Level; diff --git a/crates/gamut-png/tests/c2pa.rs b/crates/gamut-png/tests/c2pa.rs index 86d18e99..fc58e84f 100644 --- a/crates/gamut-png/tests/c2pa.rs +++ b/crates/gamut-png/tests/c2pa.rs @@ -15,7 +15,8 @@ use common::{ }; use gamut_core::{DecodeImage, Dimensions, EncodeImage, ImageBuf, ImageRef, Indexed8, Rgb8, Rgba8}; use gamut_png::{ - PhysicalUnit, PngDecoder, PngEncoder, PngPalette, SegmentKind, deconstruct, fill_c2pa, + PhysicalUnit, PngDecoder, PngEncoder, PngPalette, SegmentKind, SrgbIntent, deconstruct, + fill_c2pa, }; /// A stand-in manifest store of `len` bytes: not all zero, no two runs alike, so a fill is @@ -64,10 +65,7 @@ fn rgb_source() -> (Vec, Dimensions) { fn everything_else() -> PngEncoder { PngEncoder::new() .with_gamma(1.0 / 2.2) - // cICP rather than sRGB: §5.6 Table 5 and §11.3.2.5 say sRGB and iCCP must not both - // be written, and iCCP is the one whose payload has a size the store's placement depends - // on. cICP is legal alongside it (§4.3 Table 1 only ranks them). - .with_cicp(9, 16, true) + .with_srgb(SrgbIntent::Perceptual) .with_chromaticities((0.3127, 0.3290), (0.64, 0.33), (0.30, 0.60), (0.15, 0.06)) .with_icc_profile("Tiny", &tiny_icc_profile()) .with_significant_bits(&[8, 8, 8, 8]) diff --git a/crates/gamut-png/tests/metadata.rs b/crates/gamut-png/tests/metadata.rs index d276a32b..501498a7 100644 --- a/crates/gamut-png/tests/metadata.rs +++ b/crates/gamut-png/tests/metadata.rs @@ -11,7 +11,7 @@ use common::{ chunk, ihdr_payload, minimal_png, png_from_chunks, tiny_exif, tiny_icc_profile, zlib, }; use gamut_core::{Dimensions, EncodeImage, ErrorKind, ImageRef, Rgb8}; -use gamut_png::{PngDecoder, PngEncoder, PngMetadata}; +use gamut_png::{PngDecoder, PngEncoder, PngMetadata, SrgbIntent}; /// A 2×2 RGB8 source for the encoder-driven tests. fn source() -> Vec { @@ -50,8 +50,7 @@ fn every_carrier_round_trips_byte_exact() { .with_compressed_text("Comment", "compressed comment") .with_international_text("Title", "international title") .with_gamma(1.0 / 2.2) - // cICP rather than sRGB, which §5.6 Table 5 and §11.3.2.5 forbid beside the iCCP - // this file also carries; sRGB's own carriage is pinned by `roundtrip.rs`. + .with_srgb(SrgbIntent::RelativeColorimetric) .with_cicp(9, 16, true) .with_chromaticities( (0.3127, 0.3290), @@ -69,6 +68,7 @@ fn every_carrier_round_trips_byte_exact() { assert_eq!(meta.xmp.as_deref(), Some(xmp.as_bytes())); assert_eq!(meta.c2pa.as_deref(), Some(&c2pa[..])); assert_eq!(meta.gamma, Some(45_455)); + assert_eq!(meta.srgb, Some(SrgbIntent::RelativeColorimetric)); let cicp = meta.cicp.expect("cICP present"); assert_eq!( ( @@ -95,34 +95,27 @@ fn every_carrier_round_trips_byte_exact() { /// and not the other fails here. #[test] fn metadata_agrees_with_decode_field_for_field() { - // Built chunk by chunk rather than by the encoder, so that *every* field is populated: the - // encoder refuses sRGB beside iCCP (§5.6 Table 5, §11.3.2.5), and a comparison of two `None`s - // would not see a chunk wired into one walk and not the other. A reader still meets such a - // file, and §13.1 says an ancillary chunk it cannot use is skipped, not fatal. let exif = tiny_exif(); let icc = tiny_icc_profile(); - let mut iccp = b"Tiny\0\0".to_vec(); - iccp.extend_from_slice(&zlib(&icc)); - let mut chrm = Vec::new(); - for coord in [ - 31_270u32, 32_900, 64_000, 33_000, 30_000, 60_000, 15_000, 6_000, - ] { - chrm.extend_from_slice(&coord.to_be_bytes()); - } - let png = png_from_chunks(&[ - chunk(b"IHDR", &ihdr_payload(3, 2, 8, 2, 0)), - chunk(b"eXIf", &exif), - chunk(b"iCCP", &iccp), - chunk(b"sRGB", &[1]), - chunk(b"cICP", &[1, 13, 0, 1]), - chunk(b"gAMA", &45_455u32.to_be_bytes()), - chunk(b"cHRM", &chrm), - chunk(b"tEXt", b"Author\0nobody"), - chunk(b"iTXt", b"XML:com.adobe.xmp\0\0\0\0\0"), - chunk(b"caBX", b"\0\0\0\x10jumbc2pa"), - chunk(b"IDAT", &zlib(&[0u8; 20])), - chunk(b"IEND", &[]), - ]); + let png = encode(|e| { + e.with_exif(&exif) + .with_icc_profile("Tiny", &icc) + .with_xmp("") + .with_c2pa(b"\0\0\0\x10jumbc2pa") + .with_text("Author", "nobody") + .with_gamma(1.0 / 2.2) + .with_chromaticities( + (0.3127, 0.3290), + (0.6400, 0.3300), + (0.3000, 0.6000), + (0.1500, 0.0600), + ) + // Every colour chunk at once, including the sRGB/iCCP pair §4.3 Table 1 ranks: a + // comparison of two `None`s would not see a chunk wired into one walk and not the + // other. + .with_srgb(SrgbIntent::Perceptual) + .with_cicp(1, 13, true) + }); let meta = gamut_png::metadata(&png).unwrap(); let decoded = PngDecoder::new().decode(&png).unwrap(); diff --git a/crates/gamut-png/tests/oracle.rs b/crates/gamut-png/tests/oracle.rs index f2f8b478..fe6d2a57 100644 --- a/crates/gamut-png/tests/oracle.rs +++ b/crates/gamut-png/tests/oracle.rs @@ -322,6 +322,47 @@ fn ancillary_chunks_are_accepted_by_libpng() { assert_eq!(dec.pixels, src); } +/// The reference reader is the arbiter of whether a file carrying **both** colour chunks is a +/// file at all. §5.6 Table 5 and §11.3.2.5 say only that `sRGB` "should not" appear beside +/// `iCCP` — lowercase, and §15 gives the BCP 14 keywords force "when, and only when, they appear +/// in all capitals" — while §4.3 Table 1 presupposes the pair and ranks it. libpng reads the +/// datastream and returns the same pixels, so `PngEncoder::with_metadata` carrying both loses a +/// caller nothing. +/// +/// Note the oracle's own limit: `libpng_oracle::decode` sets `png_set_benign_errors` and drops +/// warnings, so what this pins is that the pair is not a *critical* error and the image survives +/// it, not that libpng raised no warning (issue #502), and it reads no chunk back (issue #572). +#[test] +fn a_profile_beside_a_rendering_intent_is_accepted_by_libpng() { + let (w, h) = (12u32, 12u32); + let src = rgb_pattern(w, h); + let dims = Dimensions::new(w, h).unwrap(); + let mut icc = vec![0u8; 132]; + icc[0..4].copy_from_slice(&132u32.to_be_bytes()); + icc[8..12].copy_from_slice(&0x0210_0000u32.to_be_bytes()); + icc[12..16].copy_from_slice(b"mntr"); + icc[16..20].copy_from_slice(b"RGB "); + icc[20..24].copy_from_slice(b"XYZ "); + icc[36..40].copy_from_slice(b"acsp"); + + let mut png = Vec::new(); + PngEncoder::new() + .with_icc_profile("both", &icc) + .with_srgb(SrgbIntent::Perceptual) + .encode_image(ImageRef::::new(&src, dims).unwrap(), &mut png) + .expect("encode"); + + assert!(contains_chunk(&png, b"iCCP"), "iCCP present"); + assert!(contains_chunk(&png, b"sRGB"), "sRGB present"); + assert_eq!(libpng_oracle::decode(&png).pixels, src); + + // gamut's own reader sees both too, which is what makes carrying them preservation rather + // than duplication. + let meta = gamut_png::metadata(&png).expect("read back"); + assert_eq!(meta.srgb, Some(SrgbIntent::Perceptual)); + assert_eq!(meta.icc_profile.expect("profile").profile, icc); +} + #[test] fn metadata_chunks_embed_and_image_survives() { let (w, h) = (12u32, 12u32); diff --git a/crates/gamut-png/tests/preservation.rs b/crates/gamut-png/tests/preservation.rs index 4e32fb15..79472e2c 100644 --- a/crates/gamut-png/tests/preservation.rs +++ b/crates/gamut-png/tests/preservation.rs @@ -1,16 +1,16 @@ //! `PngEncoder::with_metadata` / `with_metadata_from` (issue #483): what a re-encode carries //! forward from the file it rewrites, and what it deliberately does not. //! -//! Example and drift-guard level. No oracle: the claim is about gamut's own read→write seam, and -//! the source files are built chunk by chunk from `common` so a fixture can carry combinations -//! this encoder refuses to write — notably `sRGB` beside `iCCP`, which §5.6 Table 5 and §11.3.2.5 -//! tell encoders not to produce but which a reader still meets. +//! Example and drift-guard level, over gamut's own read→write seam. The source files are built +//! chunk by chunk from `common` so a fixture can carry exactly the combination each claim is +//! about, without the encoder's own choices standing in the way. That a re-encode's output is a +//! file the *reference* reader accepts is `tests/oracle.rs`'s job, not this file's. mod common; use common::{chunk, ihdr_payload, png_from_chunks, tiny_exif, tiny_icc_profile, zlib}; -use gamut_core::{Dimensions, EncodeImage, ImageRef, Rgb8}; -use gamut_png::{PngDecoder, PngEncoder, PngMetadata, SrgbIntent}; +use gamut_core::{Dimensions, EncodeImage, ErrorKind, ImageRef, Rgb8}; +use gamut_png::{DroppedMetadata, PngDecoder, PngEncoder, PngMetadata, SrgbIntent}; /// The `cHRM` payload for the sRGB primaries, in the ×100 000 units §11.3.2.1 stores. const CHRM: [u32; 8] = [ @@ -45,14 +45,42 @@ fn source(extra: &[Vec]) -> Vec { png_from_chunks(&chunks) } -/// Re-encodes a 2×2 image under `build`, and reads back what the output carries. -fn re_encoded(build: impl FnOnce(PngEncoder) -> PngEncoder) -> PngMetadata { +/// A source carrying only `extra` between the header and the image data — for a claim about one +/// annotation, which the full [`source`] pile would confuse with its own. +fn minimal_source(extra: &[Vec]) -> Vec { + let mut chunks = vec![chunk(b"IHDR", &ihdr_payload(3, 2, 8, 2, 0))]; + chunks.extend_from_slice(extra); + chunks.push(chunk(b"IDAT", &zlib(&[0u8; 20]))); + chunks.push(chunk(b"IEND", &[])); + png_from_chunks(&chunks) +} + +/// Re-encodes a 2×2 image under `build`, returning the output bytes. +fn re_encoded_bytes(build: impl FnOnce(PngEncoder) -> PngEncoder) -> Vec { let pixels = vec![0u8; 3 * 4]; let image = ImageRef::::new(&pixels, Dimensions::new(2, 2).unwrap()).unwrap(); - let png = build(PngEncoder::new()) + build(PngEncoder::new()) .encode_to_vec(image) - .expect("re-encode"); - gamut_png::metadata(&png).expect("read back") + .expect("re-encode") +} + +/// Re-encodes a 2×2 image under `build`, and reads back what the output carries. +fn re_encoded(build: impl FnOnce(PngEncoder) -> PngEncoder) -> PngMetadata { + gamut_png::metadata(&re_encoded_bytes(build)).expect("read back") +} + +/// The payload of the first chunk of type `ty`, for a claim about which *chunk* carries an +/// annotation rather than what text it holds — the distinction a decode erases. +fn chunk_payload(png: &[u8], ty: &[u8; 4]) -> Option> { + let mut i = 8; // past the signature + while i + 12 <= png.len() { + let len = u32::from_be_bytes([png[i], png[i + 1], png[i + 2], png[i + 3]]) as usize; + if &png[i + 4..i + 8] == ty { + return Some(png[i + 8..i + 8 + len].to_vec()); + } + i += 12 + len; + } + None } /// The headline claim of #483: nothing the read side surfaced is dropped on the way back out. @@ -92,35 +120,23 @@ fn an_itxt_keeps_its_language_and_translated_keyword() { assert_eq!(note.translated_keyword.as_deref(), Some("Notiz")); } -/// §4.3 Table 1 ranks the colour chunks and a reader honours the lowest priority number, so of a -/// source carrying both the `iCCP` (2) is the chunk that was being used and the `sRGB` (3) the -/// chunk that was being ignored. Carrying both would be the pair §5.6 Table 5 and §11.3.2.5 -/// refuse, and would make the file unencodable. +/// A source may legally carry both, and both are kept. §5.6 Table 5 and §11.3.2.5 say only that +/// `sRGB` and `iCCP` "should not" appear together — lowercase, and §15 gives the BCP 14 keywords +/// force "when, and only when, they appear in all capitals" — while §4.3 Table 1 presupposes the +/// pair and ranks it, `iCCP` (2) over `sRGB` (3). Dropping either would lose colour information +/// the source carried, which is exactly what this preservation path exists to stop. +/// +/// That the result is a file the reference reader accepts is pinned against libpng in +/// `tests/oracle.rs`. #[test] -fn srgb_gives_way_to_an_icc_profile_from_the_same_file() { +fn a_profile_and_a_rendering_intent_are_both_carried() { let meta = gamut_png::metadata(&source(&[chunk(b"sRGB", &[1])])).unwrap(); assert_eq!(meta.srgb, Some(SrgbIntent::RelativeColorimetric)); assert!(meta.icc_profile.is_some(), "the source carries both"); let re = re_encoded(|e| e.with_metadata(&meta)); - assert!(re.icc_profile.is_some(), "the ICC profile is kept"); - assert!(re.srgb.is_none(), "the lower-priority sRGB is dropped"); -} - -/// The converse: with no ICC profile to outrank it, the rendering intent is the colour -/// information the file has, and dropping it would lose it. -#[test] -fn srgb_is_carried_when_no_icc_profile_outranks_it() { - let png = png_from_chunks(&[ - chunk(b"IHDR", &ihdr_payload(3, 2, 8, 2, 0)), - chunk(b"sRGB", &[2]), - chunk(b"IDAT", &zlib(&[0u8; 20])), - chunk(b"IEND", &[]), - ]); - let meta = gamut_png::metadata(&png).unwrap(); - - let re = re_encoded(|e| e.with_metadata(&meta)); - assert_eq!(re.srgb, Some(SrgbIntent::Saturation)); + assert_eq!(re.icc_profile, meta.icc_profile); + assert_eq!(re.srgb, meta.srgb); } /// §11.3.2.6: "RGB is currently the only supported color model in PNG, and as such Matrix @@ -145,7 +161,16 @@ fn a_cicp_is_carried_only_when_its_matrix_coefficients_are_zero() { let non_rgb = gamut_png::metadata(&source(&[chunk(b"cICP", &[9, 16, 1, 1])])).unwrap(); assert!(non_rgb.cicp.is_some(), "the source carries it"); - assert!(re_encoded(|e| e.with_metadata(&non_rgb)).cicp.is_none()); + let encoder = PngEncoder::new().with_metadata(&non_rgb); + assert!(re_encoded(|_| encoder.clone()).cicp.is_none()); + // Dropped, but not in silence: the caller can say so. + assert!( + encoder + .dropped_metadata() + .contains(&DroppedMetadata::NonRgbCicp), + "{:?}", + encoder.dropped_metadata() + ); } /// Drift guard. A C2PA manifest store is signed over the exact bytes of the file it was made for, @@ -157,7 +182,12 @@ fn the_c2pa_manifest_store_is_never_carried_forward() { let meta = gamut_png::metadata(&source(&[])).unwrap(); assert!(meta.c2pa.is_some(), "the source carries a store"); - assert!(re_encoded(|e| e.with_metadata(&meta)).c2pa.is_none()); + let encoder = PngEncoder::new().with_metadata(&meta); + assert!(re_encoded(|_| encoder.clone()).c2pa.is_none()); + assert_eq!( + encoder.dropped_metadata(), + [DroppedMetadata::C2paManifestStore] + ); } /// The two entry points differ only in which read surface they take, so a field wired into one @@ -176,3 +206,93 @@ fn with_metadata_from_agrees_with_with_metadata() { assert!(from_decoded.icc_profile.is_some() && from_decoded.cicp.is_some()); assert!(!from_decoded.texts.is_empty() && from_decoded.exif.is_some()); } + +/// §11.3.3.3 makes a `zTXt` "semantically equivalent" to a `tEXt`, so a decode that keeps only the +/// text loses no *words* — but rewriting a compressed annotation uncompressed is still not +/// preservation: the fixture's 1 600-byte body is a 40-byte chunk in the source, and a re-encode +/// that forgets which chunk it came from writes it back forty times larger. +/// +/// Kills the `CompressedText` arm of `with_metadata_view`'s routing, and any mutant that collapses +/// [`TextChunkKind`](gamut_png::TextChunkKind) to one value. +#[test] +fn a_compressed_annotation_goes_back_into_a_compressed_chunk() { + let body = "the quick brown fox ".repeat(80); + let mut ztxt = b"Comment\0\0".to_vec(); + ztxt.extend_from_slice(&zlib(body.as_bytes())); + let meta = gamut_png::metadata(&minimal_source(&[chunk(b"zTXt", &ztxt)])).unwrap(); + + let out = re_encoded_bytes(|e| e.with_metadata(&meta)); + let carried = chunk_payload(&out, b"zTXt").expect("carried as zTXt"); + assert!( + chunk_payload(&out, b"tEXt").is_none(), + "not inflated to tEXt" + ); + assert!( + carried.len() < body.len() / 4, + "still compressed: {} bytes for a {}-byte body", + carried.len(), + body.len() + ); +} + +/// The same claim for the compression flag §11.3.3.4 gives `iTXt`: a compressed international +/// annotation stays compressed, and keeps the language tag and translated keyword that a plain +/// `iTXt` rewrite would have kept but a `tEXt` rewrite would have dropped. +/// +/// Kills the `CompressedInternational` arm of `with_metadata_view`'s routing. +#[test] +fn a_compressed_itxt_goes_back_into_a_compressed_itxt() { + let body = "gämut ".repeat(200); + let mut itxt = b"Note\0\x01\0de\0Notiz\0".to_vec(); + itxt.extend_from_slice(&zlib(body.as_bytes())); + let meta = gamut_png::metadata(&minimal_source(&[chunk(b"iTXt", &itxt)])).unwrap(); + + let out = re_encoded_bytes(|e| e.with_metadata(&meta)); + let note = chunk_payload(&out, b"iTXt").expect("the Note annotation"); + // keyword, NUL, compression flag 1, method 0, language, NUL, translated keyword, NUL. + assert!(note.starts_with(b"Note\0\x01\0de\0Notiz\0"), "{note:?}"); + assert!( + note.len() < body.len() / 4, + "still compressed: {} bytes", + note.len() + ); +} + +/// Carrying the same metadata twice is carrying it once. The single-value slots are idempotent +/// because a second write overwrites the first; the text list is the one place where a second +/// call would otherwise append a duplicate of every annotation — which is what a caller that +/// builds an encoder in a loop, or reuses one across files, would get. +#[test] +fn carrying_the_same_metadata_twice_carries_it_once() { + let meta = gamut_png::metadata(&source(&[])).unwrap(); + + let once = re_encoded(|e| e.with_metadata(&meta)); + let twice = re_encoded(|e| e.with_metadata(&meta).with_metadata(&meta)); + assert_eq!(once, twice); + assert_eq!(once.texts.len(), 2, "the fixture carries two annotations"); +} + +/// §11.3.3.4 gives the `iTXt` text field UTF-8 and no alternative, so a packet that is not UTF-8 +/// has no chunk this encoder can frame. The read side hands it over as raw bytes regardless — it +/// reports what the file held — so the write side is where it has to be said out loud. Refusing +/// is the point: the alternative is a caller who asked for preservation and got a file with the +/// packet missing and nothing to read about it. +#[test] +fn a_non_utf8_xmp_packet_refuses_the_re_encode() { + let mut itxt = b"XML:com.adobe.xmp\0\0\0\0\0".to_vec(); + itxt.extend_from_slice(b""); + let meta = gamut_png::metadata(&minimal_source(&[chunk(b"iTXt", &itxt)])).unwrap(); + assert!(meta.xmp.is_some(), "the read side surfaces the raw packet"); + + let pixels = vec![0u8; 3 * 4]; + let image = ImageRef::::new(&pixels, Dimensions::new(2, 2).unwrap()).unwrap(); + let error = PngEncoder::new() + .with_metadata(&meta) + .encode_to_vec(image) + .expect_err("refused"); + assert_eq!(error.kind(), ErrorKind::InvalidInput); + assert!( + error.to_string().contains("XMP packet is not UTF-8"), + "{error}" + ); +} diff --git a/crates/gamut-png/tests/roundtrip.rs b/crates/gamut-png/tests/roundtrip.rs index 75d810e8..f49aa436 100644 --- a/crates/gamut-png/tests/roundtrip.rs +++ b/crates/gamut-png/tests/roundtrip.rs @@ -258,8 +258,7 @@ fn ancillary_pile_survives_decode() { let (w, h) = (16u32, 16u32); let src = noise((w * h * 3) as usize, 9); let exif = tiny_exif(); - // No iCCP: it is the one chunk the encoder refuses beside the sRGB this pile carries (§5.6 - // Table 5, §11.3.2.5), and its carriage is pinned by `tests/metadata.rs`. + let icc = tiny_icc_profile(); let xmp = r#""#; let mut png = Vec::new(); PngEncoder::new() @@ -274,6 +273,7 @@ fn ancillary_pile_survives_decode() { .with_compressed_text("Comment", &"squeeze ".repeat(40)) .with_international_text("Author", "gämut") .with_exif(&exif) + .with_icc_profile("prof", &icc) .with_xmp(xmp) .encode_image( ImageRef::::new(&src, Dimensions::new(w, h).unwrap()).unwrap(), @@ -289,6 +289,7 @@ fn ancillary_pile_survives_decode() { assert_eq!(decoded.srgb, Some(SrgbIntent::RelativeColorimetric)); assert!(decoded.chromaticities.is_some()); assert_eq!(decoded.exif.as_deref(), Some(exif.as_slice())); + assert_eq!(decoded.icc_profile.unwrap().profile, icc); assert_eq!(decoded.xmp.as_deref(), Some(xmp.as_bytes())); assert_eq!(decoded.texts.len(), 3); } From 47b42c36e4cf2af517e83627197e1c1e19b98944 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 01:35:37 -0400 Subject: [PATCH 79/94] feat(cli): say what metadata a conversion could not carry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `gamut convert` carried a PNG input's metadata and said nothing about the payloads it could not: a C2PA manifest store, signed over the bytes of the file it was made for, and a cICP whose matrix coefficients PNG does not allow. Silent loss is the defect class this path exists to remove, so both are now warned about on stderr, which the default verbosity shows. Also corrects the claim about the second read's cost: the metadata walk is cheap — it skips IDAT by length and never inflates a pixel — but reading the file from disk again is not, and that is what taking a path rather than the already-loaded bytes costs. --- crates/gamut-cli/src/commands/convert.rs | 19 +++++++++---- crates/gamut-cli/tests/convert_metadata.rs | 33 ++++++++++++++++++---- 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/crates/gamut-cli/src/commands/convert.rs b/crates/gamut-cli/src/commands/convert.rs index de5e6412..7e9aadba 100644 --- a/crates/gamut-cli/src/commands/convert.rs +++ b/crates/gamut-cli/src/commands/convert.rs @@ -89,9 +89,10 @@ pub(crate) struct ConvertArgs { /// Drop the input's metadata instead of carrying it into the output. By default a PNG input /// re-encoded to PNG keeps its EXIF, ICC profile, XMP packet, text annotations and colour /// chunks; a stripped file is smaller, an unstripped one is colour-accurate, so the default - /// is the one that loses nothing. The C2PA manifest store is never carried either way (it is - /// signed over the bytes of the file it was made for). Currently applies only to the PNG - /// output path with a PNG input; every other pair drops metadata regardless. + /// is the one that loses nothing. Anything that cannot be carried — the C2PA manifest store, + /// signed over the bytes of the file it was made for — is reported on stderr rather than + /// dropped in silence. Currently applies only to the PNG output path with a PNG input; every + /// other pair drops metadata regardless. #[arg(long)] strip_metadata: bool, } @@ -251,8 +252,10 @@ pub(crate) fn run(args: &ConvertArgs) -> Result<(), CliError> { encoder = encoder.with_effort(effort); } // Carry the input's metadata rather than dropping it (issue #483). `png_metadata` - // reads the file a second time — cheaply: the walk skips IDAT by length and never - // inflates a pixel — and yields nothing for an input that is not a PNG. + // reads the file from disk a second time; the *walk* is cheap (it skips IDAT by + // length and never inflates a pixel), the second read is not, and it is what the + // convenience of taking a path rather than the already-loaded bytes costs. It yields + // nothing for an input that is not a PNG. let metadata = (!args.strip_metadata) .then(|| png_metadata(&args.input)) .flatten(); @@ -265,6 +268,12 @@ pub(crate) fn run(args: &ConvertArgs) -> Result<(), CliError> { "carrying input metadata" ); encoder = encoder.with_metadata(metadata); + // Say what could not come along. Silent loss is the defect this path exists to + // remove, and a payload the spec forbids carrying is still a payload the caller + // had. + for dropped in encoder.dropped_metadata() { + tracing::warn!("input metadata not carried — {dropped}"); + } } encoder.encode_image(ImageRef::::new(&rgba, dims)?, &mut out)?; (rgba.len(), dims) diff --git a/crates/gamut-cli/tests/convert_metadata.rs b/crates/gamut-cli/tests/convert_metadata.rs index 947ceb2d..1dd64a6f 100644 --- a/crates/gamut-cli/tests/convert_metadata.rs +++ b/crates/gamut-cli/tests/convert_metadata.rs @@ -13,7 +13,8 @@ use std::process::Command; use gamut::core::{Dimensions, EncodeImage, ImageRef, Rgba8}; use gamut::png::{PngEncoder, PngMetadata, SrgbIntent}; -/// A 2×2 PNG carrying an EXIF block, a text annotation and a rendering intent. +/// A 2×2 PNG carrying an EXIF block, a text annotation, a rendering intent and a C2PA manifest +/// store — the last being the one payload a re-encode may not carry. fn png_with_metadata() -> Vec { let rgba = vec![255u8; 4 * 4]; let dims = Dimensions { @@ -25,13 +26,15 @@ fn png_with_metadata() -> Vec { .with_exif(&[0x49, 0x49, 0x2A, 0x00, 0x08, 0x00, 0x00, 0x00]) .with_text("Author", "nobody") .with_srgb(SrgbIntent::Perceptual) + .with_c2pa(b"\0\0\0\x10jumbc2pa") .encode_to_vec(image) .unwrap() } /// Writes `png` to a temp file, converts it to PNG with `extra` flags, and returns the output's -/// metadata. Both temp files are removed before the assertion runs. -fn convert(name: &str, png: &[u8], extra: &[&str]) -> PngMetadata { +/// metadata together with what the command said on stderr. Both temp files are removed before +/// the assertion runs. +fn convert(name: &str, png: &[u8], extra: &[&str]) -> (PngMetadata, String) { let dir = std::env::temp_dir(); let input = dir.join(format!( "gamut-convert-{}-{name}-in.png", @@ -59,14 +62,17 @@ fn convert(name: &str, png: &[u8], extra: &[&str]) -> PngMetadata { "stderr: {}", String::from_utf8_lossy(&status.stderr) ); - gamut::png::metadata(&encoded.expect("output written")).expect("read back") + ( + gamut::png::metadata(&encoded.expect("output written")).expect("read back"), + String::from_utf8_lossy(&status.stderr).into_owned(), + ) } /// The issue's headline: `gamut convert` used to decode to raw RGBA and encode with a bare /// builder, so every EXIF, ICC, XMP and text chunk was lost with no warning. #[test] fn png_to_png_carries_the_input_metadata_by_default() { - let meta = convert("default", &png_with_metadata(), &[]); + let (meta, _) = convert("default", &png_with_metadata(), &[]); assert_eq!( meta.exif.as_deref(), @@ -85,7 +91,22 @@ fn png_to_png_carries_the_input_metadata_by_default() { /// for — the default may not silently discard colour information. #[test] fn strip_metadata_drops_it_all() { - let meta = convert("stripped", &png_with_metadata(), &["--strip-metadata"]); + let (meta, _) = convert("stripped", &png_with_metadata(), &["--strip-metadata"]); assert_eq!(meta, PngMetadata::default()); } + +/// A payload the command could not carry is *said*, not swallowed. A C2PA manifest store is +/// signed over the bytes of the file it was made for (C2PA 2.4 §A.3.2), so a copy would be +/// invalid — but the caller asked for preservation and is entitled to know their provenance did +/// not survive. Warnings reach stderr at the default verbosity, so this needs no `-v`. +#[test] +fn a_payload_that_cannot_be_carried_is_reported_on_stderr() { + let (meta, stderr) = convert("dropped", &png_with_metadata(), &[]); + + assert!(meta.c2pa.is_none(), "the store is not carried"); + assert!( + stderr.contains("C2PA manifest store"), + "stderr said nothing about the store: {stderr}" + ); +} From 0a78c0f4333a03d1b21e7ac1725e50fcc7d29264 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 01:35:37 -0400 Subject: [PATCH 80/94] docs(png): record the clauses metadata preservation implements The M1 row sat behind a blank line, so it rendered as a table of its own rather than a row of the phase table. Attach it, and rewrite the section to state the repertoire of each field as its own clause gives it, what a carry drops and names, why both colour chunks are written, and which oracle gaps stop the claim being differential today. --- crates/gamut-png/STATUS.md | 99 +++++++++++++++++++++++++------------- 1 file changed, 65 insertions(+), 34 deletions(-) diff --git a/crates/gamut-png/STATUS.md b/crates/gamut-png/STATUS.md index 7e033458..dedef130 100644 --- a/crates/gamut-png/STATUS.md +++ b/crates/gamut-png/STATUS.md @@ -40,8 +40,7 @@ opts into narrowing. That is distinct from the encoder's *lossless* auto-reduce | 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 | | C1 | C2PA 2.4 §A.3.2, §18.5.4 | **C2PA carriage** (#440): the `caBX` manifest store — raw decode surface (`c2pa`; first CRC-valid chunk before `IDAT` wins, ignored ones counted, under the metadata budget); `with_c2pa` / `with_c2pa_reserved` as the last chunk before `IDAT`; the whole-chunk exclusion span from `encode_with_report` and `PngReport::c2pa`, filled in place by `fill_c2pa` (see [C2PA](#c2pa-manifest-store-issue-440)) | ✅ done | - -| M1 | §4.3, §5.6, §11.3.2.6, §11.3.3 | **Metadata preservation** (#483): `with_metadata` / `with_metadata_from` carry a read file's eXIf/iCCP/XMP/text/colour chunks into a re-encode (`gamut convert` uses it; `--strip-metadata` opts out); `with_cicp`; `sRGB` beside `iCCP` refused and resolved by colour-chunk priority; `tEXt`/`zTXt` written as Latin-1 with promotion to `iTXt` (see [Metadata preservation](#metadata-preservation-issue-483)) | ✅ done | +| M1 | §4.3, §11.3.2.6, §11.3.3 | **Metadata preservation** (#483): `with_metadata` / `with_metadata_from` carry a read file's eXIf/iCCP/sRGB/cICP/gAMA/cHRM/XMP/text chunks into a re-encode, each annotation back into the chunk it came from (`gamut convert` uses it; `--strip-metadata` opts out; what cannot be carried is named by `dropped_metadata`); `with_cicp`; §11.3.3.1's keyword rules and §11.3.3.2/§11.3.3.4's null prohibition enforced, with promotion to `iTXt` for text outside Latin-1 (see [Metadata preservation](#metadata-preservation-issue-483)) | ✅ done | ## Decoder phases (issue #249) @@ -148,44 +147,76 @@ one private borrowed view behind two entry points, so the pixel-free `metadata() `decode()` reach it without copying a large ICC profile twice. `gamut convert` uses it on the PNG output path; `--strip-metadata` is the opt-out. **Preserve is the default**: a stripped file is smaller, but dropping an ICC profile silently changes what a viewer paints, so the loss is the -thing that has to be asked for. - -**Three spec-driven adjustments** on the way through, none of them a policy choice: - -- `iCCP` and `sRGB` are **resolved, not both written**. §5.6 Table 5 records the constraint on both - rows and §11.3.2.5 repeats it; §4.3 Table 1 then ranks the colour chunks (cICP 1, iCCP 2, sRGB 3, - cHRM+gAMA 4) and a reader honours the lowest number. So the `iCCP` is carried and the `sRGB` - dropped — the chunk a conforming reader was already ignoring. -- A `cICP` whose matrix coefficients are not 0 is dropped: §11.3.2.6 requires 0 for PNG, so such a - chunk is not conforming and carrying it forward would reproduce the defect. -- The **C2PA manifest store is never carried**. A store is signed over the exact bytes of the file - it was made for — the reason `caBX` is unsafe to copy (C2PA 2.4 §A.3.2) — so a copy is invalid by - construction. Re-sign the output and set it with `with_c2pa`. - -**Two spec defects** the same issue found, both in the writer: - -- *`sRGB` beside `iCCP` was written whenever both were set*, warned about only in a doc comment. - Now `Ancillary::validate` refuses the encode with `InvalidInput` at the one chokepoint every - encode path funnels through. Refusing rather than dropping one is the point: which the caller - meant is not guessable, and `with_metadata` exists for the case where §4.3 answers it. -- *`tEXt`/`zTXt` carried UTF-8.* §11.3.3.2 interprets a `tEXt` text string as Latin-1, §11.3.3.3 - makes an inflated `zTXt` identical to it, and §11.3.3.1 binds every keyword to Latin-1 — but the - writer pushed the Rust `String`'s bytes, storing `C3 A9` where `é` belongs. Text and keyword are - now converted once at the setter and the entry holds the bytes its chunk carries, so the wrong - encoding is unrepresentable rather than merely avoided. A text outside Latin-1 is promoted to - `iTXt` exactly as §11.3.3.2 directs, keeping the caller's compression via §11.3.3.4's flag; a - *keyword* outside it has no chunk at all, so it refuses the encode. +thing that has to be asked for. Carrying the same metadata twice carries it once — the text list +is replaced, not appended to, so the single-value colour slots and the annotations are idempotent +alike. + +**Identity, not just content.** `TextChunk::kind` records which of §11.3.3's three chunks carried +an annotation and whether its text was compressed, and a carry puts it back in the same one. +Without it a `zTXt` is indistinguishable from a `tEXt` once decoded, and a compressed 40-byte +payload comes back out as 1 600 uncompressed bytes — no words lost, but not preservation either. + +**Two payloads cannot be carried, and neither is dropped in silence.** `dropped_metadata()` names +them and `gamut convert` prints them: + +- a `cICP` whose matrix coefficients are not 0 — §11.3.2.6 requires 0 for PNG, so the source chunk + is not conforming and carrying it forward would reproduce the defect; +- the **C2PA manifest store**, signed over the exact bytes of the file it was made for, which is + why `caBX` is unsafe to copy (C2PA 2.4 §A.3.2). Re-sign the output and set it with `with_c2pa`. + +**The colour chunks are carried together, not resolved.** §5.6 Table 5 and §11.3.2.5 say only that +`sRGB` and `iCCP` "should not" appear together — lowercase, and §15 gives the BCP 14 keywords +force "when, and only when, they appear in all capitals" — while §4.3 Table 1 *presupposes* the +co-occurrence and defines the outcome by ranking the chunks (cICP 1, iCCP 2, sRGB 3, cHRM+gAMA 4). +libpng reads a file carrying both and returns the same pixels (`tests/oracle.rs`). So both are +written: dropping either would throw away colour information the source carried, and a reader +takes the one it can use. + +**The text clauses are enforced, because breaking them corrupts rather than merely offends.** +§11.3.3.1 and §11.3.3.2/§11.3.3.4 are different clauses with different repertoires, and both are +implemented as written: + +| Field | Repertoire | Clause | +| --- | --- | --- | +| Keyword (all three chunks) | code points `0x20`–`0x7E` and `0xA1`–`0xFF`; 1–79 bytes; no leading, trailing or consecutive space; expressly not U+00A0 | §11.3.3.1 | +| `tEXt`/`zTXt` text string | the keyword repertoire plus U+000A LINE FEED | §11.3.3.1 closing ¶, §11.3.3.2 | +| `iTXt` text and translated keyword | UTF-8, no null byte | §11.3.3.4 | +| `iTXt` language tag | ASCII letters, digits and `-` (BCP 47 subtags) | §11.3.3.4 | + +Text outside the `tEXt`/`zTXt` repertoire is **promoted** to `iTXt`, which is what §11.3.3.2 +directs ("Text containing characters outside the repertoire of ISO/IEC 8859-1 should be encoded +using the iTXt chunk"), keeping the caller's compression via §11.3.3.4's own flag. Because +promotion is lossless — the character survives, only the chunk changes — the tighter of §11.3.3.1's +and §11.3.3.2's two readings of "Latin-1" is taken, so a control character promotes rather than +being written with no defined meaning. + +Anything **no** chunk can carry refuses the encode with `InvalidInput`, naming the annotation's +index and keyword: a null anywhere in a keyword or text string (it is the field separator, so the +chunk re-parses as a *different* annotation), a keyword outside §11.3.3.1, an XMP packet that is +not UTF-8. A refusal is not a policy choice here — the alternative is a file that reads back as +something else, or a payload that vanishes with nothing said. + +**Two spec defects** the same issue found, both in the writer, both fixed: + +- *`tEXt`/`zTXt` carried UTF-8.* §11.3.3.2 interprets a `tEXt` text string as Latin-1 and + §11.3.3.3 makes an inflated `zTXt` identical to it, but the writer pushed the Rust `String`'s + bytes, storing `C3 A9` where `é` belongs. Text and keyword are now converted once at the setter + and the entry holds the bytes its chunk carries, so the wrong encoding is unrepresentable rather + than merely avoided. +- *`iTXt` lost its language tag and translated keyword*, the two fields that make it + international, and its compression flag. `with_cicp` (§11.3.2.6) was added with this work — without it, preservation would silently drop the highest-precedence colour chunk of any file that carries one. It takes no matrix argument: PNG fixes that byte at 0. **Not done.** `pHYs`, `tIME`, `sBIT` and `bKGD` are not part of `PngMetadata`/`DecodedPng`, so they -cannot be carried (set them with their own builder methods). A `zTXt` is indistinguishable from a -`tEXt` once decoded, so a compressed annotation is rewritten uncompressed — no text is lost, only -bytes. §11.3.3.1's keyword *syntax* rules beyond Latin-1 (the printable subset, the space rules, -the 1–79-byte bound) are not enforced. `gamut convert` carries metadata only PNG→PNG; mapping a -JPEG/WebP/JXL input's metadata into PNG chunks is a cross-format job of its own. +cannot be carried (set them with their own builder methods). The `iTXt` language tag is checked for +its character set, not for full BCP 47 well-formedness (subtag order, registry membership). +`gamut convert` carries metadata only PNG→PNG; mapping a JPEG/WebP/JXL input's metadata into PNG +chunks is a cross-format job of its own. The libpng oracle reads no chunk back and drops warnings, +so preservation is pinned against gamut's own reader plus a decode the oracle accepts — #502, #571 +and #572 are what would make it differential. ## Efficiency (issue #224) From 0314208ae256444808b03c1a0a609909bc7a4ee7 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 02:02:47 -0400 Subject: [PATCH 81/94] test(png): pin what end_carry separates and what a dropped payload is called MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two mutants the diff gate reached and no test killed. `end_carry` could be replaced with nothing: the idempotence test set its own annotation *before* the carries, where the flag's state makes no difference, so it now sets one after a carry too — the case where mistaking a direct setter for part of the carry eats it on the next one. `DroppedMetadata::reason` and its `Display` could return an empty string. The lines they produce are the whole of what a user learns about metadata that did not survive, and the test that reads them drives the `gamut` binary from `gamut-cli`, which the mutation gate cannot see. Pin the words in gamut-png's own suite. --- crates/gamut-png/src/ancillary.rs | 26 +++++++++++++++++++------- crates/gamut-png/tests/preservation.rs | 23 +++++++++++++++++++++++ 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/crates/gamut-png/src/ancillary.rs b/crates/gamut-png/src/ancillary.rs index 19e179ed..0459215d 100644 --- a/crates/gamut-png/src/ancillary.rs +++ b/crates/gamut-png/src/ancillary.rs @@ -1453,14 +1453,26 @@ mod tests { #[test] fn a_second_carry_replaces_the_first_and_spares_direct_setters() { let mut a = Ancillary::default(); - a.add_text_latin1("Mine", "kept"); - for _ in 0..2 { - a.begin_carry(); - a.add_text_latin1("Carried", "once"); - a.end_carry(); - } + a.add_text_latin1("Before", "kept"); + a.begin_carry(); + a.add_text_latin1("Carried", "once"); + a.end_carry(); + // Set *after* the carry ended: it must not be mistaken for part of it, which is what + // `end_carry` is for and what a mutant that skips it would get wrong. + a.add_text_latin1("After", "kept"); + a.begin_carry(); + a.add_text_latin1("Carried", "once"); + a.end_carry(); + let keywords: Vec<&[u8]> = a.texts.iter().map(|e| e.keyword.as_slice()).collect(); - assert_eq!(keywords, [b"Mine".as_slice(), b"Carried".as_slice()]); + assert_eq!( + keywords, + [ + b"Before".as_slice(), + b"After".as_slice(), + b"Carried".as_slice() + ] + ); } /// §11.3.2.6 Table 18 orders the payload primaries, transfer function, matrix coefficients, diff --git a/crates/gamut-png/tests/preservation.rs b/crates/gamut-png/tests/preservation.rs index 79472e2c..c39f39f2 100644 --- a/crates/gamut-png/tests/preservation.rs +++ b/crates/gamut-png/tests/preservation.rs @@ -296,3 +296,26 @@ fn a_non_utf8_xmp_packet_refuses_the_re_encode() { "{error}" ); } + +/// Naming a dropped payload is only useful if the name says something. `gamut convert` prints +/// these lines and they are the whole of what a user learns about metadata that did not survive, +/// so each has to identify the payload and give the reason it could not come along. +/// +/// Pinned here rather than in `gamut-cli`, whose tests the mutation gate cannot see: a mutant +/// that empties [`DroppedMetadata::reason`] or its `Display` would otherwise leave the command +/// printing nothing at all. +#[test] +fn a_dropped_payload_is_named_in_words() { + let store = DroppedMetadata::C2paManifestStore.to_string(); + assert!(store.contains("C2PA manifest store"), "{store}"); + assert!(store.contains("re-sign"), "{store}"); + + let cicp = DroppedMetadata::NonRgbCicp.to_string(); + assert!(cicp.contains("cICP"), "{cicp}"); + assert!(cicp.contains("matrix coefficients"), "{cicp}"); + assert_eq!( + cicp, + DroppedMetadata::NonRgbCicp.reason(), + "Display is the reason" + ); +} From cc52efddbe298c6d11b0a926989b374451373c1a Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 07:17:16 -0400 Subject: [PATCH 82/94] =?UTF-8?q?fix(png)!:=20carry=20an=20XMP=20packet's?= =?UTF-8?q?=20framing,=20and=20report=20what=20=C2=A711.3.3=20only=20advis?= =?UTF-8?q?es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four defects in the preservation path, all of them the same mistake in two directions: the writer was stricter than its own reader about clauses the specification does not bind, and looser than the file about the one field that carries a packet's identity. **A compressed XMP packet was rewritten uncompressed.** The packet leaves the read side through its own field rather than as a `TextChunk`, so `parse_itxt` bound §11.3.3.4's compression flag, language tag and translated keyword and then discarded all three for that one keyword; the writer, having no chunk-kind to consult, always emitted flag 0 with both strings empty. Measured on the fixture this commit adds: a 354-byte `iTXt` came back out as 3 734 bytes, a factor of 10.6, with the tag and translated keyword gone. `XmpFraming` now travels beside the packet on both read surfaces, and `with_xmp` — which has no source file to take framing from — takes the one §11.3.3.1 Table 21 recommends. **Setting the packet and then carrying one wrote two chunks.** A PNG carries one XMP packet under one reserved keyword, so `add_xmp` replaces rather than appends, like every other single-value payload. Appending left this crate's own first-wins reader discarding the carried packet: a silent loss inside the feature built to end silent loss. **Five keyword shapes this crate reads perfectly were refused on re-encode.** §15 gives the BCP 14 keywords force "when, and only when, they appear in all capitals", and every statement §11.3.3.1 makes about a keyword's shape is lowercase — the same argument that lets `sRGB` and `iCCP` be carried together. A leading space, a trailing space, consecutive spaces, a C0/C1 control and U+00A0 all round-trip through this crate's reader unchanged, so refusing to write them back failed a conversion over a file whose pixels are fine, and the only escape discarded the file's ICC profile too. They are now written verbatim and reported. A keyword no chunk can hold — outside Latin-1, or outside the 1–79 bytes all three chunks fix — is dropped and reported, as are a language tag outside §11.3.3.4's ASCII shape and an XMP packet that is not UTF-8. **Only a null byte still refuses**, because it is the field separator and the chunk would re-parse as a different annotation. `DroppedMetadata` becomes `MetadataNotice` and `dropped_metadata` becomes `metadata_notices`, because the channel now reports payloads that reached the output as well as payloads that did not; `MetadataNotice::carried` separates them, and `gamut convert` words the two cases differently. **§11.3.3.1 and §11.3.3.2 contradict each other about a `tEXt` text string.** §11.3.3.1's closing paragraph restricts `tEXt`/`zTXt` content to "the printable Latin-1 character set plus U+000A LINE FEED (LF)"; §11.3.3.2, which defines `tEXt`, says one sentence later that "The text string may contain any Latin-1 character". The more specific and more permissive clause is taken, so a conforming annotation is no longer silently promoted to a different chunk type. The keyword rule stays as written, being specific to keywords. BREAKING CHANGE: `DroppedMetadata` is renamed `MetadataNotice` and gains six variants; `PngEncoder::dropped_metadata() -> &[DroppedMetadata]` becomes `metadata_notices() -> Vec`. `PngMetadata` and `DecodedPng` gain an `xmp_framing` field. An encode that carried a keyword outside §11.3.3.1's repertoire, length or spacing rules, a non-ASCII `iTXt` language tag, or an XMP packet that is not UTF-8 no longer fails; read `metadata_notices()` instead. Refs #483. Refs #600. --- crates/gamut-cli/src/commands/convert.rs | 21 +- crates/gamut-png/src/ancillary.rs | 496 +++++++++++++++-------- crates/gamut-png/src/decoded.rs | 56 ++- crates/gamut-png/src/decoder.rs | 1 + crates/gamut-png/src/encoder.rs | 190 +++++++-- crates/gamut-png/src/lib.rs | 4 +- crates/gamut-png/tests/preservation.rs | 261 +++++++++++- 7 files changed, 780 insertions(+), 249 deletions(-) diff --git a/crates/gamut-cli/src/commands/convert.rs b/crates/gamut-cli/src/commands/convert.rs index 7e9aadba..9df5c53f 100644 --- a/crates/gamut-cli/src/commands/convert.rs +++ b/crates/gamut-cli/src/commands/convert.rs @@ -90,9 +90,10 @@ pub(crate) struct ConvertArgs { /// re-encoded to PNG keeps its EXIF, ICC profile, XMP packet, text annotations and colour /// chunks; a stripped file is smaller, an unstripped one is colour-accurate, so the default /// is the one that loses nothing. Anything that cannot be carried — the C2PA manifest store, - /// signed over the bytes of the file it was made for — is reported on stderr rather than - /// dropped in silence. Currently applies only to the PNG output path with a PNG input; every - /// other pair drops metadata regardless. + /// signed over the bytes of the file it was made for — and anything carried in a shape the + /// PNG specification does not endorse is reported on stderr rather than passed over in + /// silence. Currently applies only to the PNG output path with a PNG input; every other pair + /// drops metadata regardless. #[arg(long)] strip_metadata: bool, } @@ -268,11 +269,15 @@ pub(crate) fn run(args: &ConvertArgs) -> Result<(), CliError> { "carrying input metadata" ); encoder = encoder.with_metadata(metadata); - // Say what could not come along. Silent loss is the defect this path exists to - // remove, and a payload the spec forbids carrying is still a payload the caller - // had. - for dropped in encoder.dropped_metadata() { - tracing::warn!("input metadata not carried — {dropped}"); + // Say what could not come along, and what came along with a caveat. Silent loss + // is the defect this path exists to remove, and a payload the spec forbids + // carrying is still a payload the caller had. + for notice in encoder.metadata_notices() { + if notice.carried() { + tracing::warn!("input metadata carried with a caveat — {notice}"); + } else { + tracing::warn!("input metadata not carried — {notice}"); + } } } encoder.encode_image(ImageRef::::new(&rgba, dims)?, &mut out)?; diff --git a/crates/gamut-png/src/ancillary.rs b/crates/gamut-png/src/ancillary.rs index 0459215d..da7883ef 100644 --- a/crates/gamut-png/src/ancillary.rs +++ b/crates/gamut-png/src/ancillary.rs @@ -38,6 +38,7 @@ use gamut_core::{Error, Result}; use gamut_deflate::{DeflateEncoder, Level}; use crate::decoded::XMP_KEYWORD; +use crate::encoder::MetadataNotice; use crate::{ColorType, chunk}; /// The rendering intent for an `sRGB` chunk (PNG spec §11.3.2.5). @@ -135,8 +136,8 @@ impl TextKind { /// right for its `kind`, or it carries the [`fault`](Self::fault) that stops it being written. #[derive(Debug, Clone)] struct TextEntry { - /// The keyword, Latin-1 (§11.3.3.1). Empty when [`fault`](Self::fault) is set, because such - /// an entry is never written — [`Ancillary::validate`] refuses the encode first. + /// The keyword, Latin-1 (§11.3.3.1). Empty when the keyword had no Latin-1 encoding at all, + /// which is also when [`emit`](Self::emit) is clear. keyword: Vec, /// The text: Latin-1 for `tEXt`/`zTXt`, UTF-8 for `iTXt`. text: Vec, @@ -149,9 +150,21 @@ struct TextEntry { /// Whether this entry came from [`Ancillary::begin_carry`] rather than a direct setter, so a /// second carry can replace exactly what the first contributed. carried: bool, - /// Why this annotation must not be written, if it must not. Recorded here rather than - /// returned from the setter because the setters sit behind `#[must_use]` builder methods - /// that have no error channel; [`Ancillary::validate`] reports it at the encode chokepoint. + /// Whether this entry is the XMP packet (§11.3.3.1 Table 21's reserved keyword). A file + /// carries one packet, so setting it again replaces this entry rather than adding a second. + xmp: bool, + /// Whether the entry is written at all. A cleared flag keeps the entry in the list purely to + /// carry its [`notices`](Self::notices) — a payload dropped in silence is the defect this + /// module exists to remove. + emit: bool, + /// What §11.3.3 says about this annotation that the caller has to hear: a keyword no chunk + /// can hold, or one written verbatim that deviates from a recommendation. Surfaced by + /// [`PngEncoder::metadata_notices`](crate::PngEncoder::metadata_notices). + notices: Vec, + /// Why this annotation must not be written *at all*, if it must not — the null byte, and + /// only the null byte. Recorded here rather than returned from the setter because the + /// setters sit behind `#[must_use]` builder methods that have no error channel; + /// [`Ancillary::validate`] reports it at the encode chokepoint. fault: Option, } @@ -165,36 +178,18 @@ struct TextFault { reason: &'static str, } -/// §11.3.3.1: "Keywords are restricted to 1 to 79 bytes in length." -const KEYWORD_LENGTH: &str = "a keyword is restricted to 1 to 79 bytes (§11.3.3.1)"; -/// §11.3.3.1: "Keywords shall contain only printable Latin-1 [ISO_8859-1] characters and spaces; -/// that is, only code points 0x20-7E and 0xA1-FF are allowed", and expressly "nor is U+00A0 -/// NON-BREAKING SPACE". A null is outside it too, which is also §11.3.3.2's "Neither the keyword -/// nor the text string may contain a null character". -const KEYWORD_REPERTOIRE: &str = "a keyword may hold only code points 0x20-0x7E and 0xA1-0xFF \ - — no null, no control character, not U+00A0 (§11.3.3.1)"; -/// §11.3.3.1: "leading spaces, trailing spaces, and consecutive spaces are not permitted in -/// keywords". -const KEYWORD_SPACES: &str = - "a keyword may not have a leading, trailing or consecutive space (§11.3.3.1)"; /// §11.3.3.2 for `tEXt`/`zTXt` ("Neither the keyword nor the text string may contain a null /// character") and §11.3.3.4 for `iTXt` ("neither shall contain a zero byte"). The null is the /// field separator, so an embedded one does not merely offend the grammar — the chunk re-parses -/// as a *different* annotation. -const TEXT_NUL: &str = "a text string may not contain a null character (§11.3.3.2, §11.3.3.4)"; -/// §11.3.3.4: "The language tag is a well-formed language tag defined by [BCP47]", whose subtags -/// are ASCII letters and digits joined by hyphens. Anything else is neither well-formed nor -/// (being written as UTF-8 and read back as Latin-1) byte-exact. -const LANGUAGE_TAG: &str = - "an iTXt language tag may hold only ASCII letters, digits and '-' (§11.3.3.4, BCP 47)"; +/// as a *different* annotation. It is the one thing here that makes a file **mean** something +/// else, and so the one thing that refuses the encode. +const TEXT_NUL: &str = + "a keyword or text string may not contain a null character (§11.3.3.2, §11.3.3.4)"; /// §11.3.3.4: "The translated keyword and text both use the UTF-8 encoding, and neither shall -/// contain a zero byte (null character)." +/// contain a zero byte (null character)." Null-terminated like the language tag, so an embedded +/// one re-frames every field after it. const TRANSLATED_NUL: &str = "an iTXt translated keyword may not contain a null character (§11.3.3.4)"; -/// §11.3.3.4 gives the `iTXt` text field UTF-8 and no other encoding, so a packet that is not -/// UTF-8 has no chunk to go in. Dropping it silently is the loss this crate refuses to make. -const XMP_NOT_UTF8: &str = - "the XMP packet is not UTF-8, and an iTXt text string must be (§11.3.3.4)"; /// Whether `c` is a printable Latin-1 character or a space, the repertoire §11.3.3.1 spells out /// as "only code points 0x20-7E and 0xA1-FF". @@ -202,60 +197,91 @@ fn printable_latin1(c: char) -> bool { matches!(u32::from(c), 0x20..=0x7E | 0xA1..=0xFF) } -/// Whether `c` may appear in a `tEXt`/`zTXt` **text string**: §11.3.3.1's closing paragraph -/// restricts their content to "the printable Latin-1 character set plus U+000A LINE FEED (LF)". -/// -/// §11.3.3.2 says more loosely that the text "may contain any Latin-1 character", which would -/// admit the C0/C1 controls and U+00A0. The tighter reading costs nothing to take: a character -/// outside this set is not rejected, it is *promoted* to `iTXt` — exactly what §11.3.3.2's own -/// "Text containing characters outside the repertoire of ISO/IEC 8859-1 should be encoded using -/// the iTXt chunk" directs — so the character always survives and only the chunk changes. -fn text_repertoire(c: char) -> bool { - c == '\n' || printable_latin1(c) -} - /// The Latin-1 byte of `c`: Latin-1 is the first 256 Unicode code points, so the encoding is /// `u8::try_from` — the exact inverse of the decoder's `latin1`, which maps byte *n* to U+00*nn*. fn latin1_byte(c: char) -> Option { u8::try_from(u32::from(c)).ok() } -/// The Latin-1 bytes of a keyword, or the §11.3.3.1 clause it breaks. +/// What §11.3.3.1 has to say about one keyword, resolved into what the writer does with it. +/// +/// Three outcomes, because the clause mixes three kinds of statement and §15 gives them different +/// force ("when, and only when, they appear in all capitals"). Everything §11.3.3.1 says about a +/// keyword's *shape* is lowercase — "Keywords shall contain only printable Latin-1", "leading +/// spaces, trailing spaces, and consecutive spaces are not permitted", "Keywords are restricted +/// to 1 to 79 bytes" — so none of it is binding, and this crate's own reader accepts every shape +/// of keyword the length allows. What separates the outcomes is therefore not the wording but +/// the consequence: +/// +/// - a **null** is the field separator, so the chunk re-parses as a different annotation. Refuse; +/// - a keyword **no chunk can hold** — outside Latin-1, or outside the 1–79 bytes all three +/// chunks fix — is one this crate's reader and libpng both *drop*, so writing it loses the +/// annotation with nothing said. Drop it here instead, and say so; +/// - anything else round-trips through this crate's reader byte for byte, so the keyword is +/// written exactly as it arrived and the deviation is reported. Refusing it would fail a +/// conversion over a file whose pixels are fine, and the only escape would be discarding all +/// of its metadata. +enum Keyword { + /// Write these Latin-1 bytes, reporting the recommendation the keyword does not meet. + Write(Vec, Option), + /// Do not write the annotation; report why. + Drop(MetadataNotice), + /// Refuse the encode: the keyword holds the field separator. + Refuse, +} + +/// Resolves `keyword` against §11.3.3.1. /// -/// The repertoire is checked before the length so that the length bound counts *stored* bytes: -/// every character that passes is one Latin-1 byte, which a UTF-8 `str::len` is not. -fn keyword_bytes(keyword: &str) -> core::result::Result, &'static str> { - let bytes: Option> = keyword +/// Latin-1 representability is settled before the length so that the bound counts *stored* +/// bytes: every character that passes is one Latin-1 byte, which a UTF-8 `str::len` is not. +fn keyword_verdict(keyword: &str) -> Keyword { + if keyword.contains('\0') { + return Keyword::Refuse; + } + let Some(bytes) = keyword .chars() - .map(|c| latin1_byte(c).filter(|_| printable_latin1(c))) - .collect(); - let bytes = bytes.ok_or(KEYWORD_REPERTOIRE)?; + .map(latin1_byte) + .collect::>>() + else { + return Keyword::Drop(MetadataNotice::TextKeywordNotLatin1); + }; if bytes.is_empty() || bytes.len() > 79 { - return Err(KEYWORD_LENGTH); + return Keyword::Drop(MetadataNotice::TextKeywordLength); } - if keyword.starts_with(' ') || keyword.ends_with(' ') || keyword.contains(" ") { - return Err(KEYWORD_SPACES); + if !keyword.chars().all(printable_latin1) { + return Keyword::Write(bytes, Some(MetadataNotice::TextKeywordRepertoire)); } - Ok(bytes) + let spacing = keyword.starts_with(' ') || keyword.ends_with(' ') || keyword.contains(" "); + Keyword::Write(bytes, spacing.then_some(MetadataNotice::TextKeywordSpacing)) } -/// The Latin-1 bytes of a `tEXt`/`zTXt` text string, or `None` when a character is outside -/// [`text_repertoire`] — the signal to promote the annotation to `iTXt`. +/// The Latin-1 bytes of a `tEXt`/`zTXt` text string, or `None` when a character has no Latin-1 +/// encoding at all — the signal to promote the annotation to `iTXt`. +/// +/// **The specification contradicts itself here, and the more specific clause wins.** +/// §11.3.3.1's closing paragraph says of `tEXt`/`zTXt` that "There are also tEXt and zTXt chunks, +/// whose content is restricted to the printable Latin-1 character set plus U+000A LINE FEED +/// (LF)". §11.3.3.2, the clause that defines `tEXt` itself, says the opposite one sentence after +/// naming the same character set: "Text is interpreted according to the Latin-1 character set +/// [ISO_8859-1]. The text string may contain any Latin-1 character." — adding only that +/// "Characters other than those defined in Latin-1 plus the linefeed character have no defined +/// meaning in tEXt chunks", which is a statement about characters *outside* Latin-1, not inside +/// it. §11.3.3.2 is the more specific and the more permissive of the two, so it is the one taken: +/// every Latin-1 character is written into the chunk that already interprets it as Latin-1, and +/// only a character Latin-1 cannot encode promotes to `iTXt` — which is what §11.3.3.2 itself +/// directs ("Text containing characters outside the repertoire of ISO/IEC 8859-1 should be +/// encoded using the iTXt chunk"). fn text_bytes(text: &str) -> Option> { - text.chars() - .map(|c| latin1_byte(c).filter(|_| text_repertoire(c))) - .collect() + text.chars().map(latin1_byte).collect() } -/// The §11.3.3.4 clause an `iTXt`'s language tag or translated keyword breaks, if any. -fn itxt_field_fault(language: &str, translated: &str) -> Option<&'static str> { - if !language +/// Whether `language` has the shape §11.3.3.4 requires: "The language tag is a well-formed +/// language tag defined by [BCP47]", whose subtags are ASCII letters and digits joined by +/// hyphens. This checks the character set, not full BCP 47 well-formedness. +fn well_formed_language(language: &str) -> bool { + language .bytes() .all(|b| b.is_ascii_alphanumeric() || b == b'-') - { - return Some(LANGUAGE_TAG); - } - translated.contains('\0').then_some(TRANSLATED_NUL) } /// Accumulated ancillary metadata to emit alongside the image. @@ -332,42 +358,87 @@ impl Ancillary { text: &str, compressed: bool, ) { + let entry = self.itxt_entry(keyword, language, translated, text, compressed); + self.texts.push(entry); + } + + /// Builds one `iTXt` entry with its §11.3.3.4 fields, shared by the tagged text setter and + /// the XMP packet. + /// + /// A language tag outside §11.3.3.4's ASCII shape is **dropped, not refused**: written as + /// UTF-8 into a field a reader takes as Latin-1 it would not survive the trip, but the + /// annotation itself would, and an unspecified language is what §11.3.3.4 already means by + /// an empty tag. A null in the translated keyword is a different thing — it re-frames every + /// field after it — so it refuses, like every other null. + fn itxt_entry( + &self, + keyword: &str, + language: &str, + translated: &str, + text: &str, + compressed: bool, + ) -> TextEntry { let kind = if compressed { TextKind::InternationalCompressed } else { TextKind::International }; let mut entry = self.text_entry(keyword, text, kind); - if entry.fault.is_none() { - entry.fault = itxt_field_fault(language, translated).map(|reason| TextFault { + if entry.fault.is_none() && translated.contains('\0') { + entry.fault = Some(TextFault { keyword: keyword.to_string(), - reason, + reason: TRANSLATED_NUL, }); } - entry.language = language.as_bytes().to_vec(); + if well_formed_language(language) { + entry.language = language.as_bytes().to_vec(); + } else { + entry.notices.push(MetadataNotice::ItxtLanguageTag); + } entry.translated = translated.as_bytes().to_vec(); - self.texts.push(entry); + entry } - /// Adds an XMP packet as the `iTXt` §11.3.3.1 Table 21 reserves for it. + /// Adds an XMP packet as the `iTXt` §11.3.3.1 Table 21 reserves for it, framed the way the + /// file that carried it framed it (§11.3.3.4's compression flag, language tag and translated + /// keyword). + /// + /// Replaces any packet already accumulated rather than adding a second: a PNG carries one + /// XMP packet, so this is a single-value payload like `iCCP` or `eXIf`, and two `iTXt` chunks + /// under the same reserved keyword would leave a reader to pick — this crate's own reader + /// keeps the first and discards the rest. /// /// Takes bytes rather than a `&str` because that is what the read side surfaces: a file's /// packet is whatever bytes its chunk held. §11.3.3.4 gives the `iTXt` text field UTF-8 and - /// no alternative, so bytes that are not UTF-8 have no chunk to go in — and are recorded as - /// a refusal rather than discarded, because a caller that handed this encoder a packet is - /// entitled to learn it did not come out the other side. - pub(crate) fn add_xmp(&mut self, packet: &[u8]) { - match str::from_utf8(packet) { - Ok(text) => self.add_text_international(XMP_KEYWORD, text), + /// no alternative, so bytes that are not UTF-8 have no chunk to go in — and are reported + /// rather than discarded, because a caller that handed this encoder a packet is entitled to + /// learn it did not come out the other side. + pub(crate) fn add_xmp( + &mut self, + packet: &[u8], + language: &str, + translated: &str, + compressed: bool, + ) { + self.texts.retain(|entry| !entry.xmp); + let mut entry = match str::from_utf8(packet) { + Ok(text) => self.itxt_entry(XMP_KEYWORD, language, translated, text, compressed), Err(_) => { let mut entry = self.text_entry(XMP_KEYWORD, "", TextKind::International); - entry.fault = Some(TextFault { - keyword: XMP_KEYWORD.to_string(), - reason: XMP_NOT_UTF8, - }); - self.texts.push(entry); + entry.emit = false; + entry.notices.push(MetadataNotice::XmpNotUtf8); + entry } - } + }; + entry.xmp = true; + self.texts.push(entry); + } + + /// Every §11.3.3 deviation the accumulated annotations carry, in insertion order. + pub(crate) fn text_notices(&self) -> impl Iterator + '_ { + self.texts + .iter() + .flat_map(|entry| entry.notices.iter().copied()) } /// Starts carrying a read file's metadata, discarding whatever a previous carry contributed. @@ -401,16 +472,18 @@ impl Ancillary { /// promoted rather than written as bytes a Latin-1 reader mis-renders. The promotion keeps /// the caller's *other* choice, compression, because §11.3.3.4 gives `iTXt` a flag of its own. /// - /// A null in the text is the one thing promotion cannot fix — §11.3.3.2 and §11.3.3.4 both - /// forbid it, and it is the field separator, so the chunk would re-parse as a different - /// annotation — and neither can a keyword outside §11.3.3.1's repertoire, length or spacing - /// rules. Those become a [`TextFault`] the entry carries to [`Self::validate`]. + /// A null anywhere in the keyword or the text is the one thing neither promotion nor a + /// notice can fix — §11.3.3.2 and §11.3.3.4 both forbid it, and it is the field separator, so + /// the chunk would re-parse as a different annotation. It becomes a [`TextFault`] the entry + /// carries to [`Self::validate`]. Every *other* way a keyword can fall short of §11.3.3.1 is + /// a [`MetadataNotice`] instead: see [`Keyword`] for why the line is drawn there. fn text_entry(&self, keyword: &str, text: &str, kind: TextKind) -> TextEntry { - let (keyword_bytes, keyword_fault) = match keyword_bytes(keyword) { - Ok(bytes) => (bytes, None), - Err(reason) => (Vec::new(), Some(reason)), + let (keyword_bytes, emit, notice, keyword_nul) = match keyword_verdict(keyword) { + Keyword::Write(bytes, notice) => (bytes, true, notice, false), + Keyword::Drop(notice) => (Vec::new(), false, Some(notice), false), + Keyword::Refuse => (Vec::new(), true, None, true), }; - let reason = keyword_fault.or_else(|| text.contains('\0').then_some(TEXT_NUL)); + let refused = keyword_nul || text.contains('\0'); // An iTXt was asked for as UTF-8 and stays UTF-8; only a Latin-1 request has a // repertoire to leave. let latin1 = match kind { @@ -428,21 +501,27 @@ impl Ancillary { translated: Vec::new(), kind, carried: self.carrying, - fault: reason.map(|reason| TextFault { + xmp: false, + emit, + notices: notice.into_iter().collect(), + fault: refused.then(|| TextFault { keyword: keyword.to_string(), - reason, + reason: TEXT_NUL, }), } } /// Refuses an accumulation the spec forbids, before any byte is emitted. /// - /// Only the text chunks are refusable here, and only where a clause is a requirement rather - /// than a recommendation: a keyword outside §11.3.3.1's repertoire, length or spacing rules; - /// a null in a text string (§11.3.3.2, §11.3.3.4); a language tag or translated keyword - /// §11.3.3.4 rules out; a non-UTF-8 XMP packet. Each is a chunk that would be *read back as - /// something else* — the null re-frames the annotation outright — so writing it is a silent - /// corruption, and dropping it is a silent loss. + /// **Only a null byte gets here.** A null in a keyword, a text string or an `iTXt` + /// translated keyword (§11.3.3.2, §11.3.3.4) is the field separator, so a chunk carrying one + /// re-parses as a *different* annotation: the file would mean something other than what the + /// caller supplied, and no notice can undo that. Everything else §11.3.3 asks of a text + /// chunk — the keyword's repertoire, length and spacing, the `iTXt` language tag's shape, an + /// XMP packet that is not UTF-8 — is reported through + /// [`PngEncoder::metadata_notices`](crate::PngEncoder::metadata_notices) and the encode + /// proceeds. Refusing those would fail a conversion over a file whose pixels are fine, and + /// leave the caller no way out but to discard all of its metadata, colour profile included. /// /// The colour chunks are deliberately **not** policed. §5.6 Table 5 and §11.3.2.5 say only /// that `sRGB` and `iCCP` "should not" appear together, and §15 gives the BCP 14 keywords @@ -541,7 +620,8 @@ impl Ancillary { if let Some(time) = self.time { chunk::write_chunk(out, *b"tIME", &time); } - for entry in &self.texts { + // An entry with `emit` clear is a placeholder holding its notice, not a chunk. + for entry in self.texts.iter().filter(|entry| entry.emit) { write_text(out, entry, effort); } // Last, so nothing whose size could shift the store follows it: a reservation filled by @@ -1150,6 +1230,11 @@ mod tests { a.validate().expect_err("the encode is refused").to_string() } + /// The notices `a` has accumulated, in order. + fn notices(a: &Ancillary) -> Vec { + a.text_notices().collect() + } + /// A `tEXt` text string "is interpreted according to the Latin-1 character set" (§11.3.3.2), /// so a character above U+007F is **one** byte, not its UTF-8 pair. /// @@ -1187,40 +1272,25 @@ mod tests { ); } - /// §11.3.3.1 restricts a `tEXt`/`zTXt` text string to "the printable Latin-1 character set - /// plus U+000A LINE FEED (LF)", and a control character is outside it — so it promotes, for - /// the same reason a Han character does. The character survives either way; only the chunk - /// that can define it changes. + /// The specification contradicts itself about a `tEXt` text string, and the more specific and + /// more permissive clause is the one taken. §11.3.3.1's closing paragraph says `tEXt`/`zTXt` + /// "content is restricted to the printable Latin-1 character set plus U+000A LINE FEED (LF)"; + /// §11.3.3.2, which *defines* `tEXt`, says "The text string may contain any Latin-1 + /// character". A control character, a line feed and the top of Latin-1 are therefore all + /// written into the chunk that already interprets its bytes as Latin-1. /// - /// Kills [`text_repertoire`] mutated to accept everything Latin-1 can hold, which the looser - /// wording of §11.3.3.2 ("may contain any Latin-1 character") would otherwise excuse. 0x7F - /// DELETE is Latin-1-encodable and still not printable. + /// Kills [`text_bytes`] mutated to filter its characters against a narrower repertoire, which + /// would promote a conforming annotation to a different chunk type — changing the file's + /// shape over a clause the spec itself contradicts. #[test] - fn a_control_character_promotes_the_annotation_to_itxt() { + fn every_latin1_character_stays_in_a_text_chunk() { let mut a = Ancillary::default(); - a.add_text_latin1("Title", "one\u{7F}two"); - let out = post_plte(&a); - assert_eq!(find_chunk(&out, b"tEXt"), None); - assert_eq!( - find_chunk(&out, b"iTXt"), - Some(b"Title\0\0\0\0\0one\x7Ftwo".to_vec()) - ); - } - - /// The other side of the same boundary: a line feed and the top of Latin-1 are *inside* the - /// repertoire §11.3.3.1 grants `tEXt`, so neither promotes. - /// - /// Kills [`text_repertoire`] mutated to drop its `'\n'` case or to stop at 0xFE, either of - /// which would push an ordinary multi-line Latin-1 note into an `iTXt`. - #[test] - fn a_line_feed_and_the_top_of_latin1_stay_in_a_text_chunk() { - let mut a = Ancillary::default(); - a.add_text_latin1("Description", "line\nÿ"); + a.add_text_latin1("Description", "one\u{7F}two\nÿ\u{A0}"); let out = post_plte(&a); assert_eq!(find_chunk(&out, b"iTXt"), None); assert_eq!( find_chunk(&out, b"tEXt"), - Some(b"Description\0line\n\xFF".to_vec()) + Some(b"Description\0one\x7Ftwo\n\xFF\xA0".to_vec()) ); } @@ -1269,76 +1339,124 @@ mod tests { } /// §11.3.3.1: "Keywords are restricted to 1 to 79 bytes in length." Both edges, because an - /// empty keyword makes a third-party reader drop the whole annotation and an over-long one is - /// a chunk no conforming reader has to accept. + /// empty keyword makes a reader drop the whole annotation and an over-long one is a chunk no + /// conforming reader has to accept — including this crate's own, which splits a payload at + /// its first null and refuses a keyword field outside 1–79 bytes. Writing such a chunk would + /// therefore lose the annotation without a word, so it is dropped here and reported. /// - /// Kills the length guard in [`keyword_bytes`], including a mutant that shifts either bound - /// by one. + /// Kills the length guard in [`keyword_verdict`], including a mutant that shifts either bound + /// by one, and the `Drop` arm of [`Ancillary::text_entry`] that keeps the chunk out. #[test] - fn a_keyword_outside_one_to_seventy_nine_bytes_is_refused() { + fn a_keyword_outside_one_to_seventy_nine_bytes_is_dropped_with_a_notice() { let mut ok = Ancillary::default(); ok.add_text_latin1(&"k".repeat(79), "body"); ok.add_text_latin1("k", "body"); - assert!(ok.validate().is_ok(), "79 bytes and 1 byte are inside"); + assert!(notices(&ok).is_empty(), "79 bytes and 1 byte are inside"); + assert!(find_chunk(&post_plte(&ok), b"tEXt").is_some()); for keyword in ["", &"k".repeat(80)] { let mut a = Ancillary::default(); a.add_text_latin1(keyword, "body"); - assert!( - refusal(&a).contains("restricted to 1 to 79 bytes"), + assert_eq!( + notices(&a), + [MetadataNotice::TextKeywordLength], "keyword of {} bytes", keyword.len() ); + assert!(a.validate().is_ok(), "reported, not refused"); + assert_eq!(find_chunk(&post_plte(&a), b"tEXt"), None); } } + /// §11.3.3.1 binds a keyword to Latin-1 in all three text chunks, so a character Latin-1 + /// cannot encode has no chunk to go in — unlike a *text string*, which §11.3.3.2 routes to + /// `iTXt`. The annotation is dropped and reported rather than transliterated. + /// + /// Kills the `Drop(TextKeywordNotLatin1)` arm of [`keyword_verdict`]: with it gone the + /// keyword's UTF-8 bytes reach a field a reader takes as Latin-1. + #[test] + fn a_keyword_outside_latin1_is_dropped_with_a_notice() { + let mut a = Ancillary::default(); + a.add_text_latin1("题", "body"); + assert_eq!(notices(&a), [MetadataNotice::TextKeywordNotLatin1]); + assert!(a.validate().is_ok(), "reported, not refused"); + assert_eq!(find_chunk(&post_plte(&a), b"tEXt"), None); + } + /// §11.3.3.1: "only code points 0x20-7E and 0xA1-FF are allowed", and expressly "nor is /// U+00A0 NON-BREAKING SPACE since it is visually indistinguishable from an ordinary space". - /// The null is the same clause read through §11.3.3.2 — and the one that *corrupts* rather - /// than merely offends, because it is the field separator: `Auth\0or` re-parses as the - /// annotation `Auth`. + /// Lowercase "shall", so §15 makes it advisory, and this crate's reader returns such a + /// keyword unchanged — so the keyword is **written verbatim** and the deviation reported. /// - /// Kills the repertoire guard in [`keyword_bytes`] and each edge of [`printable_latin1`]. + /// Kills each edge of [`printable_latin1`] and the `Write(_, Some(..))` arm of + /// [`keyword_verdict`]: a mutant that stops noticing leaves the caller unwarned, and one that + /// drops the annotation loses metadata the file had. #[test] - fn a_keyword_outside_the_printable_latin1_repertoire_is_refused() { + fn a_keyword_outside_the_printable_latin1_repertoire_is_written_with_a_notice() { for keyword in [ - "Auth\0or", // the field separator itself - "Auth\u{7F}", // DELETE - "Auth\u{9F}", // C1 control - "Auth\u{A0}", // NON-BREAKING SPACE, named by the clause - "题", // outside Latin-1 altogether + "Auth\u{7F}or", // DELETE + "Auth\u{9F}or", // C1 control + "Auth\u{A0}or", // NON-BREAKING SPACE, named by the clause ] { let mut a = Ancillary::default(); a.add_text_latin1(keyword, "body"); - assert!( - refusal(&a).contains("code points 0x20-0x7E and 0xA1-0xFF"), + assert_eq!( + notices(&a), + [MetadataNotice::TextKeywordRepertoire], "keyword {keyword:?}" ); + let mut expected = keyword.chars().map(|c| c as u8).collect::>(); + expected.extend_from_slice(b"\0body"); + assert_eq!(find_chunk(&post_plte(&a), b"tEXt"), Some(expected)); } let mut edges = Ancillary::default(); edges.add_text_latin1("a\u{20}b\u{7E}\u{A1}\u{FF}", "body"); - assert!(edges.validate().is_ok(), "0x20, 0x7E, 0xA1 and 0xFF are in"); + assert!( + notices(&edges).is_empty(), + "0x20, 0x7E, 0xA1 and 0xFF are in" + ); } /// §11.3.3.1: "leading spaces, trailing spaces, and consecutive spaces are not permitted in - /// keywords", so that a keyword cannot be misread as another. + /// keywords", so that a keyword cannot be misread as another. Lowercase again, and again a + /// keyword this crate's reader hands back unchanged, so it is written and reported. /// - /// Kills the spacing guard in [`keyword_bytes`], one condition at a time. + /// Kills the spacing guard in [`keyword_verdict`], one condition at a time. #[test] - fn a_keyword_with_a_leading_trailing_or_consecutive_space_is_refused() { + fn a_keyword_with_a_leading_trailing_or_consecutive_space_is_written_with_a_notice() { for keyword in [" Author", "Author ", "Two Words"] { let mut a = Ancillary::default(); a.add_text_latin1(keyword, "body"); - assert!( - refusal(&a).contains("leading, trailing or consecutive space"), + assert_eq!( + notices(&a), + [MetadataNotice::TextKeywordSpacing], "keyword {keyword:?}" ); + let mut expected = keyword.as_bytes().to_vec(); + expected.extend_from_slice(b"\0body"); + assert_eq!(find_chunk(&post_plte(&a), b"tEXt"), Some(expected)); } let mut ok = Ancillary::default(); ok.add_text_latin1("Two Words", "body"); - assert!(ok.validate().is_ok(), "a single interior space is allowed"); + assert!( + notices(&ok).is_empty(), + "a single interior space is allowed" + ); + } + + /// A null in the *keyword* is the field separator, so `Auth\0or` re-parses as the annotation + /// `Auth` with `or` for its text: the chunk means something the caller never wrote. That — + /// and only that — still refuses, which is the line between what this module reports and what + /// it rejects. + /// + /// Kills the `Refuse` arm of [`keyword_verdict`], which no notice test can reach. + #[test] + fn a_null_in_a_keyword_is_refused() { + let mut a = Ancillary::default(); + a.add_text_latin1("Auth\0or", "body"); + assert!(refusal(&a).contains("may not contain a null character")); } /// §11.3.3.2: "Neither the keyword nor the text string may contain a null character", and @@ -1372,18 +1490,28 @@ mod tests { } /// §11.3.3.4: "The language tag is a well-formed language tag defined by [BCP47]", whose - /// subtags are ASCII letters and digits joined by hyphens. Anything else is not a tag, and — - /// written as UTF-8 into a field a reader takes as Latin-1 — would not even survive the trip. + /// subtags are ASCII letters and digits joined by hyphens. Anything else, written as UTF-8 + /// into a field a reader takes as Latin-1, would not survive the trip — so the **tag** goes + /// and the annotation stays, an empty tag being §11.3.3.4's own way of saying the language is + /// unspecified. /// - /// Kills the language arm of [`itxt_field_fault`], and the empty case pins that "unspecified" - /// stays legal. + /// Kills the language arm of [`Ancillary::itxt_entry`]; the empty case pins that + /// "unspecified" is not itself a deviation. #[test] - fn a_language_tag_outside_bcp_47_is_refused() { + fn a_language_tag_outside_bcp_47_is_dropped_with_a_notice() { for language in ["de\0DE", "zh_Hans", "dé"] { let mut a = Ancillary::default(); a.add_text_international_tagged("Note", language, "", "body", false); - assert!( - refusal(&a).contains("ASCII letters, digits and '-'"), + assert_eq!( + notices(&a), + [MetadataNotice::ItxtLanguageTag], + "language {language:?}" + ); + assert!(a.validate().is_ok(), "reported, not refused"); + // keyword, NUL, flag, method, *empty* language, NUL, empty translated keyword, NUL. + assert_eq!( + find_chunk(&post_plte(&a), b"iTXt"), + Some(b"Note\0\0\0\0\0body".to_vec()), "language {language:?}" ); } @@ -1392,29 +1520,57 @@ mod tests { ok.add_text_international_tagged("Note", "", "", "body", false); ok.add_text_international_tagged("Note", "ar-AE-u-nu-latn", "", "body", false); assert!( - ok.validate().is_ok(), + notices(&ok).is_empty(), "empty and a full BCP 47 tag are fine" ); } /// §11.3.3.4 gives the `iTXt` text field UTF-8 and no alternative, so a packet that is not - /// UTF-8 has no chunk to go in. It is refused rather than quietly discarded: the read side + /// UTF-8 has no chunk to go in. It is reported rather than quietly discarded: the read side /// surfaces a packet as raw bytes, and a caller that handed those bytes back is entitled to - /// learn they did not come out the other side. + /// learn they did not come out the other side. It does not refuse, because the rest of the + /// file — pixels, colour profile — is fine. /// - /// Kills the `Err` arm of [`Ancillary::add_xmp`] — with it gone the packet vanishes silently. + /// Kills the `Err` arm of [`Ancillary::add_xmp`] — with it gone the packet vanishes silently + /// — and its `emit` flag, without which the packet's *keyword* is written with no packet. #[test] - fn a_non_utf8_xmp_packet_is_refused() { + fn a_non_utf8_xmp_packet_is_reported_not_written() { let mut a = Ancillary::default(); - a.add_xmp(b""); - assert!(refusal(&a).contains("XMP packet is not UTF-8")); + a.add_xmp(b"", "", "", false); + assert_eq!(notices(&a), [MetadataNotice::XmpNotUtf8]); + assert!(a.validate().is_ok(), "reported, not refused"); + assert_eq!(find_chunk(&post_plte(&a), b"iTXt"), None); let mut valid = Ancillary::default(); - valid.add_xmp(b""); - assert!(valid.validate().is_ok(), "a UTF-8 packet is carried"); + valid.add_xmp(b"", "", "", false); + assert!(notices(&valid).is_empty(), "a UTF-8 packet is carried"); assert!(find_chunk(&post_plte(&valid), b"iTXt").is_some()); } + /// A PNG carries one XMP packet, and §11.3.3.1 Table 21 reserves one keyword for it, so the + /// encoder's packet is a single-value payload: setting it again replaces it. Appending would + /// write two `iTXt` chunks under that keyword, and this crate's reader keeps the first — so + /// the packet set *last* would be the one silently discarded. + /// + /// Kills the `retain` in [`Ancillary::add_xmp`]. Asserted on the written chunk rather than on + /// the entry list because it is the chunk count a reader sees. + #[test] + fn setting_an_xmp_packet_twice_writes_one_chunk() { + let mut a = Ancillary::default(); + a.add_xmp(b"", "", "", false); + a.add_xmp(b"", "", "", false); + let out = post_plte(&a); + assert_eq!( + find_chunk(&out, b"iTXt"), + Some(b"XML:com.adobe.xmp\0\0\0\0\0".to_vec()) + ); + assert_eq!( + out.windows(4).filter(|w| *w == b"iTXt").count(), + 1, + "one chunk, not two" + ); + } + /// A refusal a caller cannot act on is barely better than a silent drop, so it names *which* /// annotation offended — its position and its keyword, escaped so a null shows up. /// diff --git a/crates/gamut-png/src/decoded.rs b/crates/gamut-png/src/decoded.rs index 06551367..220aeb0f 100644 --- a/crates/gamut-png/src/decoded.rs +++ b/crates/gamut-png/src/decoded.rs @@ -125,6 +125,32 @@ pub enum TextChunkKind { CompressedInternational = 3, } +/// How a file framed its XMP packet inside the `iTXt` chunk §11.3.3.1 Table 21 reserves for it. +/// +/// The packet itself is [`DecodedPng::xmp`] / [`PngMetadata::xmp`]; this is everything *else* the +/// chunk carried, and it is `Some` exactly when the packet is. It exists for the same reason +/// [`TextChunkKind`] does — a re-encode has to put the packet back the way it came out — but the +/// packet is surfaced as its own field rather than as a [`TextChunk`], so the framing needs its +/// own home. Table 21 *recommends* the null framing (`compressed` clear, both strings empty) for +/// XMP compliance; it does not require it, and a file that frames it otherwise is still a file +/// whose bytes have to survive a re-encode. +/// +/// Marked `#[non_exhaustive]`: consolidating the packet into [`PngMetadata::texts`] would retire +/// this type, and that is a decision of its own (issue #600). Until then the pairing is a +/// convention, not a type: a caller assembling a [`PngMetadata`] by hand can set one field +/// without the other, and the encoder then takes this type's [`Default`] framing. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[non_exhaustive] +pub struct XmpFraming { + /// The chunk's language tag (§11.3.3.4), if it carried a non-empty one. + pub language: Option, + /// The chunk's translated keyword (§11.3.3.4), if it carried a non-empty one. + pub translated_keyword: Option, + /// Whether the packet was stored zlib-compressed (§11.3.3.4's compression flag). A packet + /// stored compressed and rewritten uncompressed is the same words at many times the size. + pub compressed: bool, +} + /// One text annotation (tEXt/zTXt/iTXt, §11.3.3), decompressed where stored compressed. /// /// tEXt/zTXt hold Latin-1, mapped code-point-for-code-point into the `String` (lossless); @@ -167,9 +193,12 @@ pub struct DecodedPng { pub exif: Option>, /// The embedded ICC profile (iCCP), inflated. Feed as `MetadataBlock::Icc`. pub icc_profile: Option, - /// The XMP packet (the `XML:com.adobe.xmp` iTXt, §11.3.3.2), decompressed if stored + /// The XMP packet (the `XML:com.adobe.xmp` iTXt, §11.3.3.4), decompressed if stored /// compressed. Feed as `MetadataBlock::Xmp`. pub xmp: Option>, + /// How the chunk that carried [`xmp`](Self::xmp) framed it: its compression flag, language + /// tag and translated keyword (§11.3.3.4). `Some` exactly when `xmp` is. + pub xmp_framing: Option, /// The C2PA manifest store (the `caBX` chunk, C2PA 2.4 §A.3.2) verbatim: the JUMBF bytes, /// uncompressed, exactly as the chunk carries them — opaque here, never parsed or judged. /// Feed as `MetadataBlock::C2pa`. The first CRC-valid `caBX` before the first `IDAT`, and @@ -243,9 +272,12 @@ pub struct PngMetadata { pub exif: Option>, /// The embedded ICC profile (iCCP), inflated. Feed as `MetadataBlock::Icc`. pub icc_profile: Option, - /// The XMP packet (the `XML:com.adobe.xmp` iTXt, §11.3.3.2), decompressed if stored + /// The XMP packet (the `XML:com.adobe.xmp` iTXt, §11.3.3.4), decompressed if stored /// compressed. Feed as `MetadataBlock::Xmp`. pub xmp: Option>, + /// How the chunk that carried [`xmp`](Self::xmp) framed it: its compression flag, language + /// tag and translated keyword (§11.3.3.4). `Some` exactly when `xmp` is. + pub xmp_framing: Option, /// The C2PA manifest store (the `caBX` chunk, C2PA 2.4 §A.3.2) verbatim and uncompressed — /// opaque bytes, never parsed or judged. Feed as `MetadataBlock::C2pa`. The first CRC-valid /// `caBX` before the first `IDAT`, and only when it fits the metadata budget; see @@ -351,9 +383,10 @@ pub(crate) fn collect(chunks: &[([u8; 4], &[u8])], budget: usize) -> PngMetadata } } b"iTXt" => match parse_itxt(data, &mut budget) { - Some(ITxt::Xmp(packet)) => { + Some(ITxt::Xmp(packet, framing)) => { if meta.xmp.is_none() { meta.xmp = Some(packet); + meta.xmp_framing = Some(framing); } } Some(ITxt::Text(text)) => meta.texts.push(text), @@ -369,9 +402,10 @@ pub(crate) fn collect(chunks: &[([u8; 4], &[u8])], budget: usize) -> PngMetadata /// by §11.3.3.1 Table 21. Shared with the encoder so the two sides cannot disagree on it. pub(crate) const XMP_KEYWORD: &str = "XML:com.adobe.xmp"; -/// A parsed iTXt: either the XMP packet or an ordinary text annotation. +/// A parsed iTXt: either the XMP packet and how its chunk framed it, or an ordinary text +/// annotation. enum ITxt { - Xmp(Vec), + Xmp(Vec, XmpFraming), Text(TextChunk), } @@ -475,7 +509,17 @@ fn parse_itxt(data: &[u8], budget: &mut usize) -> Option { _ => return None, }; if keyword == XMP_KEYWORD { - return Some(ITxt::Xmp(text_bytes)); + // The packet leaves by its own field, so everything the chunk framed it with — the + // compression flag above all — leaves beside it rather than with the annotation list. + // Without the flag a 71-byte chunk is rewritten as thousands of uncompressed bytes. + return Some(ITxt::Xmp( + text_bytes, + XmpFraming { + language: Some(language).filter(|l| !l.is_empty()), + translated_keyword: Some(translated).filter(|t| !t.is_empty()), + compressed: flag == 1, + }, + )); } Some(ITxt::Text(TextChunk { keyword, diff --git a/crates/gamut-png/src/decoder.rs b/crates/gamut-png/src/decoder.rs index cc7cb071..1ffc18f2 100644 --- a/crates/gamut-png/src/decoder.rs +++ b/crates/gamut-png/src/decoder.rs @@ -436,6 +436,7 @@ impl PngDecoder { exif: meta.exif, icc_profile: meta.icc_profile, xmp: meta.xmp, + xmp_framing: meta.xmp_framing, c2pa: meta.c2pa, c2pa_ignored: meta.c2pa_ignored, texts: meta.texts, diff --git a/crates/gamut-png/src/encoder.rs b/crates/gamut-png/src/encoder.rs index 716b839b..1484d879 100644 --- a/crates/gamut-png/src/encoder.rs +++ b/crates/gamut-png/src/encoder.rs @@ -32,7 +32,7 @@ use crate::backend::{IdatDeflater, IdatInfo, Registry, run_deflaters}; use crate::chunk::{self, C2paSpan, SIGNATURE}; use crate::color::ColorType; use crate::decoded::{ - Chromaticities, Cicp, DecodedPng, IccProfile, PngMetadata, TextChunk, TextChunkKind, + Chromaticities, Cicp, DecodedPng, IccProfile, PngMetadata, TextChunk, TextChunkKind, XmpFraming, }; use crate::filter::{self, FilterStrategy, FilterType}; use crate::palette::PngPalette; @@ -87,6 +87,9 @@ struct MetadataView<'a> { exif: Option<&'a [u8]>, icc_profile: Option<&'a IccProfile>, xmp: Option<&'a [u8]>, + /// How the source framed its XMP packet (§11.3.3.4): compression flag, language tag, + /// translated keyword. Carried beside the packet because the packet has its own field. + xmp_framing: Option<&'a XmpFraming>, texts: &'a [TextChunk], gamma: Option, chromaticities: Option, @@ -97,33 +100,66 @@ struct MetadataView<'a> { c2pa: bool, } -/// A metadata payload [`PngEncoder::with_metadata`] could not carry into the output. +/// Something [`PngEncoder::with_metadata`] could not do faithfully with a payload it was given. /// -/// Preservation exists to stop metadata disappearing quietly, so the two payloads a carry cannot -/// take are named rather than dropped in silence. Read them back with -/// [`PngEncoder::dropped_metadata`] and tell the user — `gamut convert` does. +/// Preservation exists to stop metadata disappearing quietly, so anything a carry cannot take — +/// and anything it takes only by writing bytes the specification does not endorse — is named +/// rather than passed over. Read them back with [`PngEncoder::metadata_notices`] and tell the +/// user; `gamut convert` does. [`carried`](Self::carried) separates the two cases: a payload +/// left behind from one that reached the output with a caveat on it. +/// +/// This is deliberately **not** an error channel. The only thing that stops an encode is a null +/// byte in a text field, which makes the chunk re-parse as a different annotation; everything +/// here is something a caller has to *know*, not something that should fail a conversion whose +/// pixels are fine. /// /// `#[repr(u8)]` with explicit discriminants, which are permanent and append-only: the value /// crosses the C ABI as a plain integer. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[repr(u8)] #[non_exhaustive] -pub enum DroppedMetadata { - /// A `cICP` whose matrix coefficients are not 0. §11.3.2.6 requires 0 for PNG — "RGB is - /// currently the only supported color model in PNG, and as such Matrix Coefficients shall be - /// set to 0" — so the source chunk is not conforming and copying it forward would reproduce - /// the defect in a file this encoder signed off on. +pub enum MetadataNotice { + /// A `cICP` whose matrix coefficients are not 0, left behind. §11.3.2.6 requires 0 for PNG — + /// "RGB is currently the only supported color model in PNG, and as such Matrix Coefficients + /// shall be set to 0" — so the source chunk is not conforming and copying it forward would + /// reproduce the defect in a file this encoder signed off on. NonRgbCicp = 0, - /// The C2PA manifest store (`caBX`). A store is signed over the exact bytes of the file it - /// was made for, which is why C2PA 2.4 §A.3.2 marks the chunk unsafe to copy: carried into a - /// re-encode it is invalid by construction, and a validator reports a *tampered* file rather - /// than an unsigned one. Re-sign the output and set it with + /// The C2PA manifest store (`caBX`), left behind. A store is signed over the exact bytes of + /// the file it was made for, which is why C2PA 2.4 §A.3.2 marks the chunk unsafe to copy: + /// carried into a re-encode it is invalid by construction, and a validator reports a + /// *tampered* file rather than an unsigned one. Re-sign the output and set it with /// [`with_c2pa`](PngEncoder::with_c2pa). C2paManifestStore = 1, + /// A text annotation left behind because its keyword holds a character Latin-1 cannot + /// encode. §11.3.3.1 binds the keyword to Latin-1 in *all three* text chunks, so unlike the + /// text — which §11.3.3.2 routes to `iTXt` — there is no chunk that could carry it. + TextKeywordNotLatin1 = 2, + /// A text annotation left behind because its keyword is empty or longer than the 79 bytes + /// §11.3.3.1 allows. All three chunks fix that field at 1–79 bytes, so a reader — this + /// crate's own included — drops the whole chunk rather than reading a longer one. + TextKeywordLength = 3, + /// A text annotation **written**, whose keyword leaves the repertoire §11.3.3.1 recommends + /// ("only code points 0x20-7E and 0xA1-FF are allowed", and expressly "nor is U+00A0 + /// NON-BREAKING SPACE"). The keyword is written exactly as it arrived — this crate reads it + /// back unchanged — but another reader need not be so forgiving. + TextKeywordRepertoire = 4, + /// A text annotation **written**, whose keyword has a leading, trailing or consecutive + /// space, which §11.3.3.1 says are "not permitted in keywords" so that one keyword cannot be + /// misread as another. Written as it arrived, for the same reason as + /// [`TextKeywordRepertoire`](Self::TextKeywordRepertoire). + TextKeywordSpacing = 5, + /// A text annotation **written without its `iTXt` language tag**, because the tag was not + /// the ASCII shape §11.3.3.4 requires ("a well-formed language tag defined by [BCP47]"). + /// Written as UTF-8 into a field a reader takes as Latin-1 the tag would not survive the + /// trip; an empty tag is §11.3.3.4's own way of saying the language is unspecified. + ItxtLanguageTag = 6, + /// An XMP packet left behind because it is not UTF-8. §11.3.3.4 gives the `iTXt` text field + /// UTF-8 and no alternative, so there is no chunk to frame it in. + XmpNotUtf8 = 7, } -impl DroppedMetadata { - /// One line naming what was left behind and why, fit to show a user. +impl MetadataNotice { + /// One line naming the payload and what happened to it, fit to show a user. #[must_use] pub fn reason(self) -> &'static str { match self { @@ -134,11 +170,48 @@ impl DroppedMetadata { "C2PA manifest store: signed over the source bytes, so a copy would be invalid \ (C2PA 2.4 §A.3.2) — re-sign the output" } + Self::TextKeywordNotLatin1 => { + "text annotation: its keyword is not Latin-1, which every text chunk requires \ + (§11.3.3.1)" + } + Self::TextKeywordLength => { + "text annotation: its keyword is not 1 to 79 bytes, the length every text chunk \ + fixes (§11.3.3.1)" + } + Self::TextKeywordRepertoire => { + "text annotation: written, but its keyword leaves the code points 0x20-0x7E and \ + 0xA1-0xFF §11.3.3.1 recommends — another reader may reject it" + } + Self::TextKeywordSpacing => { + "text annotation: written, but its keyword has a leading, trailing or \ + consecutive space, which §11.3.3.1 does not permit" + } + Self::ItxtLanguageTag => { + "text annotation: written without its language tag, which was not the BCP 47 \ + shape §11.3.3.4 requires" + } + Self::XmpNotUtf8 => { + "XMP packet: not UTF-8, and an iTXt text string must be (§11.3.3.4)" + } } } + + /// Whether the payload still reached the output. + /// + /// `false` means it was left behind entirely; `true` means it was written, with the caveat + /// [`reason`](Self::reason) gives. A caller showing these to a user needs the difference — + /// "this did not come along" and "this came along in a form some readers dislike" call for + /// different action. + #[must_use] + pub fn carried(self) -> bool { + matches!( + self, + Self::TextKeywordRepertoire | Self::TextKeywordSpacing | Self::ItxtLanguageTag + ) + } } -impl core::fmt::Display for DroppedMetadata { +impl core::fmt::Display for MetadataNotice { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { f.write_str(self.reason()) } @@ -154,9 +227,11 @@ pub struct PngEncoder { auto_reduce: bool, clean_transparent: bool, backends: Registry, - /// What the last metadata carry could not take, in the order it was found. Reset by each - /// [`Self::with_metadata`] / [`Self::with_metadata_from`] call, so it describes that call. - dropped: Vec, + /// What the last metadata carry could not take *as a whole payload*, in the order it was + /// found. Reset by each [`Self::with_metadata`] / [`Self::with_metadata_from`] call, so it + /// describes that call. Per-annotation notices live with their annotation instead, so that a + /// second carry replaces them exactly as it replaces the annotations themselves. + carry_notices: Vec, } impl Default for PngEncoder { @@ -178,7 +253,7 @@ impl PngEncoder { auto_reduce: false, clean_transparent: false, backends: Registry::default(), - dropped: Vec::new(), + carry_notices: Vec::new(), } } @@ -468,7 +543,11 @@ impl PngEncoder { /// the XMP/RDF document — for example the bytes produced by `gamut-xmp`. #[must_use] pub fn with_xmp(mut self, xmp: &str) -> Self { - self.ancillary.add_xmp(xmp.as_bytes()); + // §11.3.3.1 Table 21: "The use of iTXt, with Compression Flag set to 0, and both Language + // Tag and Translated Keyword set to the null string, are recommended for XMP compliance." + // A packet read out of a file that framed it otherwise keeps its framing; this entry + // point has no framing to keep, so it takes the recommended one. + self.ancillary.add_xmp(xmp.as_bytes(), "", "", false); self } @@ -491,18 +570,24 @@ impl PngEncoder { /// together — §4.3 Table 1 ranks the colour chunks precisely so a file may carry more than /// one, and a reader honours the lowest priority number. Each text annotation goes back into /// the chunk it came out of, compressed if it was compressed - /// ([`TextChunkKind`](crate::TextChunkKind)). + /// ([`TextChunkKind`](crate::TextChunkKind)); so does the XMP packet, whose own framing — + /// compression flag, language tag, translated keyword — rides in + /// [`XmpFraming`](crate::XmpFraming). /// - /// Two payloads cannot be carried, and both are **named** rather than dropped in silence — - /// read them back with [`dropped_metadata`](Self::dropped_metadata): + /// Two payloads cannot be carried at all, and neither is dropped in silence — read them back + /// with [`metadata_notices`](Self::metadata_notices): /// /// - a **`cICP` whose matrix coefficients are not 0**, which §11.3.2.6 does not allow in PNG; /// - the **C2PA manifest store**, signed over the bytes of the file it was made for. /// - /// Anything that would be *corrupted* rather than lost — a keyword outside §11.3.3.1's - /// repertoire, a null inside a text string, an XMP packet that is not UTF-8 — makes the - /// encode fail with [`Error::InvalidInput`] naming the annotation, rather than being written - /// as something a reader reads back differently. + /// A text annotation whose keyword or XMP packet §11.3.3 does not endorse is reported through + /// the same channel rather than failing the carry: a keyword outside §11.3.3.1's repertoire + /// or spacing rules is written as it arrived, a keyword no chunk can hold and an XMP packet + /// that is not UTF-8 are left behind, and + /// [`MetadataNotice::carried`](MetadataNotice::carried) says which happened. **Only a null** + /// in a keyword or text string fails the encode with [`Error::InvalidInput`] naming the + /// annotation — the null is the field separator, so the chunk would be read back as a + /// *different* annotation, which no notice can undo. /// /// One further limit is the read side's, not this method's: `pHYs`, `tIME`, `sBIT` and `bKGD` /// are not part of [`PngMetadata`], so they cannot be carried here (set them with their own @@ -513,6 +598,7 @@ impl PngEncoder { exif: metadata.exif.as_deref(), icc_profile: metadata.icc_profile.as_ref(), xmp: metadata.xmp.as_deref(), + xmp_framing: metadata.xmp_framing.as_ref(), texts: &metadata.texts, gamma: metadata.gamma, chromaticities: metadata.chromaticities, @@ -533,6 +619,7 @@ impl PngEncoder { exif: decoded.exif.as_deref(), icc_profile: decoded.icc_profile.as_ref(), xmp: decoded.xmp.as_deref(), + xmp_framing: decoded.xmp_framing.as_ref(), texts: &decoded.texts, gamma: decoded.gamma, chromaticities: decoded.chromaticities, @@ -542,22 +629,30 @@ impl PngEncoder { }) } - /// What the last [`with_metadata`](Self::with_metadata) / - /// [`with_metadata_from`](Self::with_metadata_from) call could not carry, in the order it was - /// found — empty when it carried everything, and reset by each call. + /// What this encoder could not carry faithfully: whole payloads left behind, then the + /// per-annotation notices, in the order they were found — empty when everything came along + /// intact. /// /// Surface this to whoever asked for the re-encode. Losing metadata without saying so is the - /// defect the preservation path exists to remove; losing it *with* an explanation is a - /// choice the spec forces. + /// defect the preservation path exists to remove; losing it — or bending it — *with* an + /// explanation is a choice the spec forces. Use + /// [`MetadataNotice::carried`](MetadataNotice::carried) to tell the two apart. + /// + /// The payload-level notices describe the last [`with_metadata`](Self::with_metadata) / + /// [`with_metadata_from`](Self::with_metadata_from) call and are reset by each; the + /// per-annotation notices belong to the annotations still accumulated, so they follow the + /// same replace-not-append rule a carry gives the text list. #[must_use] - pub fn dropped_metadata(&self) -> &[DroppedMetadata] { - &self.dropped + pub fn metadata_notices(&self) -> Vec { + let mut notices = self.carry_notices.clone(); + notices.extend(self.ancillary.text_notices()); + notices } /// The one implementation behind [`with_metadata`](Self::with_metadata) and /// [`with_metadata_from`](Self::with_metadata_from). fn with_metadata_view(mut self, meta: MetadataView<'_>) -> Self { - self.dropped.clear(); + self.carry_notices.clear(); self.ancillary.begin_carry(); if let Some(exif) = meta.exif { self = self.with_exif(exif); @@ -577,7 +672,7 @@ impl PngEncoder { // otherwise is not a conforming cICP; carrying it forward would put the same defect // in the output. Some(cicp) if cicp.matrix_coefficients != 0 => { - self.dropped.push(DroppedMetadata::NonRgbCicp); + self.carry_notices.push(MetadataNotice::NonRgbCicp); } Some(cicp) => { self = self.with_cicp( @@ -589,7 +684,7 @@ impl PngEncoder { None => {} } if meta.c2pa { - self.dropped.push(DroppedMetadata::C2paManifestStore); + self.carry_notices.push(MetadataNotice::C2paManifestStore); } // Set in the stored ×100 000 fixed-point units rather than through `with_gamma` / // `with_chromaticities`, whose `f64` arguments would round-trip the value through a @@ -609,10 +704,21 @@ impl PngEncoder { chrm.blue.1, ]); } - // Handed over as bytes, because that is what the chunk held. §11.3.3.4 requires UTF-8, so - // a packet that is not gets a refusal at `encode` naming it — never a silent drop. + // Handed over as bytes, because that is what the chunk held, and with the framing its + // chunk gave it — above all §11.3.3.4's compression flag, without which a packet stored + // as 71 compressed bytes is rewritten as the 4 045 it inflates to. §11.3.3.4 requires + // UTF-8, so a packet that is not is reported by `metadata_notices` — never a silent drop. if let Some(xmp) = meta.xmp { - self.ancillary.add_xmp(xmp); + let (language, translated, compressed) = + meta.xmp_framing.map_or(("", "", false), |f| { + ( + f.language.as_deref().unwrap_or_default(), + f.translated_keyword.as_deref().unwrap_or_default(), + f.compressed, + ) + }); + self.ancillary + .add_xmp(xmp, language, translated, compressed); } for text in meta.texts { let (language, translated) = ( diff --git a/crates/gamut-png/src/lib.rs b/crates/gamut-png/src/lib.rs index ab7fdce5..ba4cab1c 100644 --- a/crates/gamut-png/src/lib.rs +++ b/crates/gamut-png/src/lib.rs @@ -91,14 +91,14 @@ pub use chunk::{C2paSpan, fill_c2pa}; pub use color::ColorType; pub use decoded::{ Chromaticities, Cicp, DecodedPng, IccProfile, PngHeader, PngImage, PngMetadata, TextChunk, - TextChunkKind, + TextChunkKind, XmpFraming, }; pub use decoder::{PngDecoder, TransparencyKey, metadata}; pub use deconstruct::{ ChunkStats, DEFAULT_MAX_CHUNKS, DeconstructLimits, FilterHistogram, FilterScan, PassStats, PngReport, Segment, SegmentKind, SkippedFilterScan, deconstruct, deconstruct_with_limits, }; -pub use encoder::{DroppedMetadata, PngEncodeReport, PngEncoder}; +pub use encoder::{MetadataNotice, PngEncodeReport, PngEncoder}; pub use filter::{FilterStrategy, FilterType}; /// The DEFLATE compression level, accepted by [`PngEncoder::with_compression`]. pub use gamut_deflate::Level; diff --git a/crates/gamut-png/tests/preservation.rs b/crates/gamut-png/tests/preservation.rs index c39f39f2..1592df7b 100644 --- a/crates/gamut-png/tests/preservation.rs +++ b/crates/gamut-png/tests/preservation.rs @@ -10,7 +10,7 @@ mod common; use common::{chunk, ihdr_payload, png_from_chunks, tiny_exif, tiny_icc_profile, zlib}; use gamut_core::{Dimensions, EncodeImage, ErrorKind, ImageRef, Rgb8}; -use gamut_png::{DroppedMetadata, PngDecoder, PngEncoder, PngMetadata, SrgbIntent}; +use gamut_png::{MetadataNotice, PngDecoder, PngEncoder, PngMetadata, SrgbIntent}; /// The `cHRM` payload for the sRGB primaries, in the ×100 000 units §11.3.2.1 stores. const CHRM: [u32; 8] = [ @@ -36,7 +36,7 @@ fn source(extra: &[Vec]) -> Vec { chunk(b"cHRM", &chrm), chunk(b"tEXt", b"Author\0caf\xE9"), chunk(b"iTXt", b"Note\0\0\0de\0Notiz\0g\xC3\xA4mut"), - chunk(b"iTXt", b"XML:com.adobe.xmp\0\0\0\0\0"), + chunk(b"iTXt", &compressed_xmp()), chunk(b"caBX", b"\0\0\0\x10jumbc2pa"), ]; chunks.extend_from_slice(extra); @@ -45,6 +45,42 @@ fn source(extra: &[Vec]) -> Vec { png_from_chunks(&chunks) } +/// The `iTXt` payload for a **compressed** XMP packet carrying both §11.3.3.4 fields. +/// +/// §11.3.3.1 Table 21 recommends the null framing for XMP compliance — flag 0, both strings +/// empty — but recommends is all it does, and a provenance packet is exactly the payload a +/// writer compresses. The uncompressed fixture that stood here could not see the flag being +/// dropped, which is how a 57× inflation went unnoticed. +fn compressed_xmp() -> Vec { + let mut itxt = b"XML:com.adobe.xmp\0\x01\0en\0Metadata\0".to_vec(); + itxt.extend_from_slice(&zlib(&xmp_packet())); + itxt +} + +/// A realistic XMP packet: repetitive RDF followed by the whitespace padding XMP Part 3 +/// recommends so an in-place update can grow without rewriting the file. That padding is exactly +/// why a real packet is stored compressed, and exactly what a writer that loses the compression +/// flag puts back in full. +fn xmp_packet() -> Vec { + let mut packet = XMP_RDF.as_bytes().to_vec(); + packet.resize(packet.len() + 3_072, b' '); + packet.extend_from_slice(b""); + packet +} + +/// The RDF body of [`xmp_packet`]. +const XMP_RDF: &str = concat!( + "", + "", + "", + "a title", + "a creator", + "a notice", + "a description", + "", +); + /// A source carrying only `extra` between the header and the image data — for a claim about one /// annotation, which the full [`source`] pile would confuse with its own. fn minimal_source(extra: &[Vec]) -> Vec { @@ -93,6 +129,7 @@ fn every_carried_chunk_survives_a_re_encode() { assert_eq!(re.exif, meta.exif); assert_eq!(re.icc_profile, meta.icc_profile); assert_eq!(re.xmp, meta.xmp); + assert_eq!(re.xmp_framing, meta.xmp_framing); assert_eq!(re.gamma, Some(45_455)); let chrm = re.chromaticities.expect("cHRM carried"); assert_eq!( @@ -166,10 +203,10 @@ fn a_cicp_is_carried_only_when_its_matrix_coefficients_are_zero() { // Dropped, but not in silence: the caller can say so. assert!( encoder - .dropped_metadata() - .contains(&DroppedMetadata::NonRgbCicp), + .metadata_notices() + .contains(&MetadataNotice::NonRgbCicp), "{:?}", - encoder.dropped_metadata() + encoder.metadata_notices() ); } @@ -185,8 +222,8 @@ fn the_c2pa_manifest_store_is_never_carried_forward() { let encoder = PngEncoder::new().with_metadata(&meta); assert!(re_encoded(|_| encoder.clone()).c2pa.is_none()); assert_eq!( - encoder.dropped_metadata(), - [DroppedMetadata::C2paManifestStore] + encoder.metadata_notices(), + [MetadataNotice::C2paManifestStore] ); } @@ -258,6 +295,144 @@ fn a_compressed_itxt_goes_back_into_a_compressed_itxt() { ); } +/// The same claim for the XMP packet, which is where it was untrue: the packet leaves the read +/// side through its own field, so the `iTXt` framing that field does *not* hold — §11.3.3.4's +/// compression flag above all — has to travel beside it or be invented at the writer. +/// +/// A provenance packet is exactly the payload a writer compresses, and rewriting one +/// uncompressed inflates it by a factor a user notices. Kills a mutant that ignores +/// [`XmpFraming::compressed`](gamut_png::XmpFraming::compressed). +#[test] +fn a_compressed_xmp_packet_goes_back_into_a_compressed_itxt() { + let meta = gamut_png::metadata(&minimal_source(&[chunk(b"iTXt", &compressed_xmp())])).unwrap(); + assert_eq!(meta.xmp, Some(xmp_packet())); + + let out = re_encoded_bytes(|e| e.with_metadata(&meta)); + let carried = chunk_payload(&out, b"iTXt").expect("the packet"); + // keyword, NUL, then §11.3.3.4's compression flag. + assert_eq!(carried[18], 1, "the flag is set: {carried:?}"); + + // Measured against the same carry with the flag cleared, so the claim is the inflation the + // flag prevents rather than a threshold that happens to hold for this packet. + let mut flat = meta.clone(); + flat.xmp_framing.as_mut().expect("framed").compressed = false; + let flat_out = re_encoded_bytes(|e| e.with_metadata(&flat)); + let inflated = chunk_payload(&flat_out, b"iTXt").expect("the packet"); + assert!( + carried.len() * 2 < inflated.len(), + "{} bytes compressed against {} uncompressed", + carried.len(), + inflated.len() + ); +} + +/// §11.3.3.4's language tag and translated keyword are as much a part of the XMP chunk as of any +/// other `iTXt`, and §11.3.3.1 Table 21 only *recommends* leaving them empty. A file that fills +/// them is a file whose bytes have to come back. +/// +/// Separate from the compression claim above because a writer can keep the flag and still drop +/// the two strings — the defect this pins was exactly that pair going missing together. +#[test] +fn an_xmp_packet_keeps_its_language_and_translated_keyword() { + let meta = gamut_png::metadata(&source(&[])).unwrap(); + let framing = meta.xmp_framing.clone().expect("the source frames it"); + assert_eq!(framing.language.as_deref(), Some("en")); + assert_eq!(framing.translated_keyword.as_deref(), Some("Metadata")); + assert!(framing.compressed); + + let re = re_encoded(|e| e.with_metadata(&meta)); + assert_eq!(re.xmp_framing, Some(framing)); +} + +/// A PNG carries one XMP packet, so the encoder's packet is a single-value payload like `eXIf` or +/// `iCCP`: setting it again replaces it. Appending instead wrote two `iTXt` chunks under the one +/// keyword §11.3.3.1 Table 21 reserves, and this crate's reader keeps the *first* — so the packet +/// a caller carried in was the one silently discarded, inside the feature built to end silent +/// discarding. +#[test] +fn carrying_an_xmp_packet_replaces_one_already_set() { + let meta = gamut_png::metadata(&source(&[])).unwrap(); + let out = re_encoded_bytes(|e| { + e.with_xmp("") + .with_metadata(&meta) + }); + + let mut packets = 0; + let mut i = 8; + while i + 12 <= out.len() { + let len = u32::from_be_bytes([out[i], out[i + 1], out[i + 2], out[i + 3]]) as usize; + if &out[i + 4..i + 8] == b"iTXt" && out[i + 8..].starts_with(b"XML:com.adobe.xmp\0") { + packets += 1; + } + i += 12 + len; + } + assert_eq!(packets, 1, "one keyword, one chunk"); + assert_eq!( + gamut_png::metadata(&out).unwrap().xmp, + meta.xmp, + "and it is the carried packet, not the one it replaced" + ); +} + +/// §11.3.3.1's keyword *shape* rules are lowercase throughout — "Keywords shall contain only +/// printable Latin-1", "leading spaces, trailing spaces, and consecutive spaces are not +/// permitted" — and §15 gives the BCP 14 keywords force "when, and only when, they appear in all +/// capitals". This crate's reader accepts every one of these keywords and returns them +/// unchanged, so refusing to write them back would fail a conversion over a file whose pixels +/// are fine, leaving no escape but to discard the file's metadata entirely. +/// +/// So they are written verbatim and reported. Kills a mutant that turns any of these back into a +/// refusal, or that drops the annotation instead of writing it. +#[test] +fn a_keyword_the_reader_accepts_survives_the_re_encode_with_a_notice() { + for (keyword, notice) in [ + (" Author", MetadataNotice::TextKeywordSpacing), + ("Author ", MetadataNotice::TextKeywordSpacing), + ("Two Words", MetadataNotice::TextKeywordSpacing), + ("Auth\u{7F}or", MetadataNotice::TextKeywordRepertoire), + ("Auth\u{A0}or", MetadataNotice::TextKeywordRepertoire), + ] { + let mut text = keyword.as_bytes().to_vec(); + text.extend_from_slice(b"\0body"); + let png = minimal_source(&[chunk(b"tEXt", &text)]); + let meta = gamut_png::metadata(&png).unwrap(); + assert_eq!(meta.texts.len(), 1, "the reader accepts {keyword:?}"); + + let encoder = PngEncoder::new().with_metadata(&meta); + assert_eq!(encoder.metadata_notices(), [notice], "keyword {keyword:?}"); + let re = re_encoded(|_| encoder.clone()); + assert_eq!(re.texts, meta.texts, "keyword {keyword:?} came back whole"); + } +} + +/// The other half of the same line: a keyword no chunk can hold is left behind rather than +/// written, because all three text chunks fix that field at 1–79 Latin-1 bytes and a reader — +/// this crate's own included — drops a chunk whose keyword busts it. Writing it would be the +/// silent loss, so the annotation goes and the notice stays. +/// +/// Driven through the setters, because the reader will not produce such a keyword from a file. +#[test] +fn a_keyword_no_chunk_can_hold_is_left_behind_with_a_notice() { + for (keyword, notice) in [ + ("", MetadataNotice::TextKeywordLength), + (&"k".repeat(80), MetadataNotice::TextKeywordLength), + ("题", MetadataNotice::TextKeywordNotLatin1), + ] { + let encoder = PngEncoder::new().with_text(keyword, "body"); + assert_eq!( + encoder.metadata_notices(), + [notice], + "keyword of {} chars", + keyword.chars().count() + ); + assert!( + re_encoded(|_| encoder.clone()).texts.is_empty(), + "keyword of {} chars was not written", + keyword.chars().count() + ); + } +} + /// Carrying the same metadata twice is carrying it once. The single-value slots are idempotent /// because a second write overwrites the first; the text list is the one place where a second /// call would otherwise append a duplicate of every annotation — which is what a caller that @@ -274,48 +449,92 @@ fn carrying_the_same_metadata_twice_carries_it_once() { /// §11.3.3.4 gives the `iTXt` text field UTF-8 and no alternative, so a packet that is not UTF-8 /// has no chunk this encoder can frame. The read side hands it over as raw bytes regardless — it -/// reports what the file held — so the write side is where it has to be said out loud. Refusing -/// is the point: the alternative is a caller who asked for preservation and got a file with the -/// packet missing and nothing to read about it. +/// reports what the file held — so the write side is where it has to be said out loud. It is +/// **reported, not refused**: the pixels of such a file are fine, and failing the whole encode +/// would leave a caller no way to convert it but to discard its ICC profile too. #[test] -fn a_non_utf8_xmp_packet_refuses_the_re_encode() { +fn a_non_utf8_xmp_packet_is_reported_and_the_re_encode_proceeds() { let mut itxt = b"XML:com.adobe.xmp\0\0\0\0\0".to_vec(); itxt.extend_from_slice(b""); let meta = gamut_png::metadata(&minimal_source(&[chunk(b"iTXt", &itxt)])).unwrap(); assert!(meta.xmp.is_some(), "the read side surfaces the raw packet"); + let encoder = PngEncoder::new().with_metadata(&meta); + assert_eq!(encoder.metadata_notices(), [MetadataNotice::XmpNotUtf8]); + assert!( + !MetadataNotice::XmpNotUtf8.carried(), + "the packet is left behind, not written" + ); + assert!(re_encoded(|_| encoder.clone()).xmp.is_none()); +} + +/// A null byte is the one thing that still refuses, because it is the field separator: a `tEXt` +/// carrying `Note\0Author\0other` re-parses as a *different* annotation, so writing it would make +/// the file mean something the caller never supplied. No notice can undo that. +#[test] +fn a_null_in_a_carried_text_string_refuses_the_re_encode() { + // Built through the setter rather than a fixture: the reader splits a chunk at its first + // null, so no file can hand a null to the carry — only a caller can. let pixels = vec![0u8; 3 * 4]; let image = ImageRef::::new(&pixels, Dimensions::new(2, 2).unwrap()).unwrap(); let error = PngEncoder::new() - .with_metadata(&meta) + .with_text("Note", "before\0after") .encode_to_vec(image) .expect_err("refused"); assert_eq!(error.kind(), ErrorKind::InvalidInput); assert!( - error.to_string().contains("XMP packet is not UTF-8"), + error + .to_string() + .contains("may not contain a null character"), "{error}" ); } -/// Naming a dropped payload is only useful if the name says something. `gamut convert` prints -/// these lines and they are the whole of what a user learns about metadata that did not survive, -/// so each has to identify the payload and give the reason it could not come along. +/// Naming a payload is only useful if the name says something. `gamut convert` prints these +/// lines and they are the whole of what a user learns about metadata that did not survive +/// intact, so each has to identify the payload and give the reason. /// /// Pinned here rather than in `gamut-cli`, whose tests the mutation gate cannot see: a mutant -/// that empties [`DroppedMetadata::reason`] or its `Display` would otherwise leave the command +/// that empties [`MetadataNotice::reason`] or its `Display` would otherwise leave the command /// printing nothing at all. #[test] -fn a_dropped_payload_is_named_in_words() { - let store = DroppedMetadata::C2paManifestStore.to_string(); +fn a_notice_names_its_payload_in_words() { + let store = MetadataNotice::C2paManifestStore.to_string(); assert!(store.contains("C2PA manifest store"), "{store}"); assert!(store.contains("re-sign"), "{store}"); - let cicp = DroppedMetadata::NonRgbCicp.to_string(); + let cicp = MetadataNotice::NonRgbCicp.to_string(); assert!(cicp.contains("cICP"), "{cicp}"); assert!(cicp.contains("matrix coefficients"), "{cicp}"); assert_eq!( cicp, - DroppedMetadata::NonRgbCicp.reason(), + MetadataNotice::NonRgbCicp.reason(), "Display is the reason" ); } + +/// The whole point of the channel is that "it did not come along" and "it came along bent" are +/// different news for a user, so [`MetadataNotice::carried`] has to separate them — and it is +/// the only thing that does. +/// +/// Kills a mutant that makes `carried` constant either way, which would have `gamut convert` +/// telling a user their ICC profile was dropped when it was not. +#[test] +fn a_notice_says_whether_the_payload_reached_the_output() { + for carried in [ + MetadataNotice::TextKeywordRepertoire, + MetadataNotice::TextKeywordSpacing, + MetadataNotice::ItxtLanguageTag, + ] { + assert!(carried.carried(), "{carried:?}"); + } + for lost in [ + MetadataNotice::NonRgbCicp, + MetadataNotice::C2paManifestStore, + MetadataNotice::TextKeywordNotLatin1, + MetadataNotice::TextKeywordLength, + MetadataNotice::XmpNotUtf8, + ] { + assert!(!lost.carried(), "{lost:?}"); + } +} From 5d714256167647ac953a3b80b96f830bba6c9e65 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 07:17:27 -0400 Subject: [PATCH 83/94] docs(png): correct what preservation carries and what it only reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The metadata-preservation section claimed identity was preserved under a heading about identity, while the XMP packet — the largest payload the path carries — lost its compression flag, language tag and translated keyword. It also listed §11.3.3.1's keyword rules as enforced, when enforcing them refused five keyword shapes this crate's own reader accepts. Records instead: what the XMP packet's framing costs when it is lost (a 354-byte `iTXt` rewritten as 3 734, measured on the fixture); the three-way split between what refuses the encode, what is dropped and reported, and what is written verbatim and reported, with the §15 argument for the line; and the §11.3.3.1/§11.3.3.2 contradiction about a `tEXt` text string, quoting both halves from the vendored text rather than picking one silently. The "not done" list gains the seams #600 would close — the packet's parallel fields and its position among the annotations — and the efficiency table's metadata-hygiene axis no longer says `gamut convert` drops metadata on the PNG path, which this work made untrue. Refs #483. Refs #600. --- crates/gamut-png/STATUS.md | 92 ++++++++++++++++++++++++++------------ 1 file changed, 63 insertions(+), 29 deletions(-) diff --git a/crates/gamut-png/STATUS.md b/crates/gamut-png/STATUS.md index dedef130..3c34b64a 100644 --- a/crates/gamut-png/STATUS.md +++ b/crates/gamut-png/STATUS.md @@ -40,7 +40,7 @@ opts into narrowing. That is distinct from the encoder's *lossless* auto-reduce | 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 | | C1 | C2PA 2.4 §A.3.2, §18.5.4 | **C2PA carriage** (#440): the `caBX` manifest store — raw decode surface (`c2pa`; first CRC-valid chunk before `IDAT` wins, ignored ones counted, under the metadata budget); `with_c2pa` / `with_c2pa_reserved` as the last chunk before `IDAT`; the whole-chunk exclusion span from `encode_with_report` and `PngReport::c2pa`, filled in place by `fill_c2pa` (see [C2PA](#c2pa-manifest-store-issue-440)) | ✅ done | -| M1 | §4.3, §11.3.2.6, §11.3.3 | **Metadata preservation** (#483): `with_metadata` / `with_metadata_from` carry a read file's eXIf/iCCP/sRGB/cICP/gAMA/cHRM/XMP/text chunks into a re-encode, each annotation back into the chunk it came from (`gamut convert` uses it; `--strip-metadata` opts out; what cannot be carried is named by `dropped_metadata`); `with_cicp`; §11.3.3.1's keyword rules and §11.3.3.2/§11.3.3.4's null prohibition enforced, with promotion to `iTXt` for text outside Latin-1 (see [Metadata preservation](#metadata-preservation-issue-483)) | ✅ done | +| M1 | §4.3, §11.3.2.6, §11.3.3 | **Metadata preservation** (#483): `with_metadata` / `with_metadata_from` carry a read file's eXIf/iCCP/sRGB/cICP/gAMA/cHRM/XMP/text chunks into a re-encode, each annotation back into the chunk it came from and the XMP packet back into the framing its `iTXt` gave it (`gamut convert` uses it; `--strip-metadata` opts out; what could not be carried faithfully is named by `metadata_notices`); `with_cicp`; §11.3.3.2/§11.3.3.4's null prohibition refuses the encode and §11.3.3.1's advisory keyword rules report through the notice channel, with promotion to `iTXt` for text outside Latin-1 (see [Metadata preservation](#metadata-preservation-issue-483)) | ✅ done | ## Decoder phases (issue #249) @@ -156,7 +156,23 @@ an annotation and whether its text was compressed, and a carry puts it back in t Without it a `zTXt` is indistinguishable from a `tEXt` once decoded, and a compressed 40-byte payload comes back out as 1 600 uncompressed bytes — no words lost, but not preservation either. -**Two payloads cannot be carried, and neither is dropped in silence.** `dropped_metadata()` names +The **XMP packet leaves the read side through its own field**, not through `texts`, so the framing +that field does not hold travels beside it in `XmpFraming`: §11.3.3.4's compression flag, language +tag and translated keyword. §11.3.3.1 Table 21 recommends the null framing for XMP compliance +("with Compression Flag set to 0, and both Language Tag and Translated Keyword set to the null +string") — recommends, not requires, and a provenance packet is exactly the payload a writer +compresses. The measured cost of getting this wrong, on the fixture in `tests/preservation.rs`: a +354-byte `iTXt` rewritten as 3 734 bytes, a factor of 10.6, with the language tag and translated +keyword gone as well. `with_xmp` — which has no source file to take framing from — takes Table 21's +recommended framing. The packet is a **single-value payload** like `iCCP` or `eXIf`: setting it +again replaces it, because a second `iTXt` under the reserved keyword is one this crate's own +reader discards. + +Consolidating the packet into `texts` would retire `XmpFraming` and put the packet back in its +file position rather than first among the annotations; it reshapes a public type, so it is +[#600](https://github.com/visualcommons/gamut/issues/600), not this work. + +**Two payloads cannot be carried, and neither is dropped in silence.** `metadata_notices()` names them and `gamut convert` prints them: - a `cICP` whose matrix coefficients are not 0 — §11.3.2.6 requires 0 for PNG, so the source chunk @@ -172,29 +188,43 @@ libpng reads a file carrying both and returns the same pixels (`tests/oracle.rs` written: dropping either would throw away colour information the source carried, and a reader takes the one it can use. -**The text clauses are enforced, because breaking them corrupts rather than merely offends.** -§11.3.3.1 and §11.3.3.2/§11.3.3.4 are different clauses with different repertoires, and both are -implemented as written: +**Only the null byte refuses the encode. Everything else §11.3.3 asks for is a notice.** +§15 gives the BCP 14 keywords force "when, and only when, they appear in all capitals", and every +statement §11.3.3.1 makes about a keyword's shape is lowercase — "Keywords shall contain only +printable Latin-1", "leading spaces, trailing spaces, and consecutive spaces are not permitted", +"Keywords are restricted to 1 to 79 bytes in length". The same argument that lets `sRGB` and +`iCCP` be carried together applies here, so what separates the outcomes is the *consequence*, not +the wording: -| Field | Repertoire | Clause | +| Field | Clause | Outcome | | --- | --- | --- | -| Keyword (all three chunks) | code points `0x20`–`0x7E` and `0xA1`–`0xFF`; 1–79 bytes; no leading, trailing or consecutive space; expressly not U+00A0 | §11.3.3.1 | -| `tEXt`/`zTXt` text string | the keyword repertoire plus U+000A LINE FEED | §11.3.3.1 closing ¶, §11.3.3.2 | -| `iTXt` text and translated keyword | UTF-8, no null byte | §11.3.3.4 | -| `iTXt` language tag | ASCII letters, digits and `-` (BCP 47 subtags) | §11.3.3.4 | - -Text outside the `tEXt`/`zTXt` repertoire is **promoted** to `iTXt`, which is what §11.3.3.2 -directs ("Text containing characters outside the repertoire of ISO/IEC 8859-1 should be encoded -using the iTXt chunk"), keeping the caller's compression via §11.3.3.4's own flag. Because -promotion is lossless — the character survives, only the chunk changes — the tighter of §11.3.3.1's -and §11.3.3.2's two readings of "Latin-1" is taken, so a control character promotes rather than -being written with no defined meaning. - -Anything **no** chunk can carry refuses the encode with `InvalidInput`, naming the annotation's -index and keyword: a null anywhere in a keyword or text string (it is the field separator, so the -chunk re-parses as a *different* annotation), a keyword outside §11.3.3.1, an XMP packet that is -not UTF-8. A refusal is not a policy choice here — the alternative is a file that reads back as -something else, or a payload that vanishes with nothing said. +| A null in a keyword or text string | §11.3.3.2, §11.3.3.4 | **refuses the encode** — the null is the field separator, so the chunk re-parses as a *different* annotation | +| Keyword outside Latin-1, or outside 1–79 bytes | §11.3.3.1 | annotation **dropped**, `TextKeywordNotLatin1` / `TextKeywordLength` — no chunk can hold it, and this crate's own reader drops one that tries | +| Keyword outside `0x20`–`0x7E` / `0xA1`–`0xFF`, or with a leading, trailing or consecutive space | §11.3.3.1 | **written verbatim**, `TextKeywordRepertoire` / `TextKeywordSpacing` | +| `iTXt` language tag outside ASCII letters, digits and `-` | §11.3.3.4 | tag **dropped**, annotation written, `ItxtLanguageTag` | +| XMP packet that is not UTF-8 | §11.3.3.4 | packet **dropped**, `XmpNotUtf8` | + +The written-verbatim row is the important one, and it is where an earlier draft of this work got +it wrong. Five keyword shapes — a leading space, a trailing space, consecutive spaces, a C0/C1 +control, U+00A0 — are ones this crate's *reader* accepts and returns unchanged. Refusing to write +them back made a re-encode fail on a file whose pixels are fine, and the only escape was +`--strip-metadata`, which discards the ICC profile too. A writer must not be stricter than its own +reader about a clause that is advisory in the first place; `MetadataNotice::carried()` tells a +caller which of these reached the output. + +**The specification contradicts itself about a `tEXt` text string, and the more specific clause +wins.** §11.3.3.1's closing paragraph: "There are also tEXt and zTXt chunks, whose content is +restricted to the printable Latin-1 character set plus U+000A LINE FEED (LF)." §11.3.3.2, which +defines `tEXt`: "Text is interpreted according to the Latin-1 character set [ISO_8859-1]. The text +string may contain any Latin-1 character." Both are in `references/png/png-3.html`. §11.3.3.2 is +the more specific and the more permissive, so it is taken: every Latin-1 character is written into +the chunk that already interprets its bytes as Latin-1, and only a character Latin-1 cannot encode +**promotes** to `iTXt` — which is what §11.3.3.2 itself directs ("Text containing characters +outside the repertoire of ISO/IEC 8859-1 should be encoded using the iTXt chunk"), keeping the +caller's compression via §11.3.3.4's own flag. Taking the tighter reading silently changed a +conforming annotation's chunk *type*, which contradicts the identity claim above. The keyword rule +stays as §11.3.3.1 writes it, because that clause is specific to keywords and all three chunks +share it. **Two spec defects** the same issue found, both in the writer, both fixed: @@ -212,11 +242,15 @@ fixes that byte at 0. **Not done.** `pHYs`, `tIME`, `sBIT` and `bKGD` are not part of `PngMetadata`/`DecodedPng`, so they cannot be carried (set them with their own builder methods). The `iTXt` language tag is checked for -its character set, not for full BCP 47 well-formedness (subtag order, registry membership). -`gamut convert` carries metadata only PNG→PNG; mapping a JPEG/WebP/JXL input's metadata into PNG -chunks is a cross-format job of its own. The libpng oracle reads no chunk back and drops warnings, -so preservation is pinned against gamut's own reader plus a decode the oracle accepts — #502, #571 -and #572 are what would make it differential. +its character set, not for full BCP 47 well-formedness (subtag order, registry membership). The XMP +packet rides in its own field beside `XmpFraming` rather than in `texts`, so a carry emits it +**first** among the annotations regardless of where it sat in the source, and the two fields can be +set inconsistently by a caller building a `PngMetadata` by hand — #600. `sPLT` and `hIST` are +surfaced by neither read walk, so they are not carried either. `gamut convert` carries metadata +only PNG→PNG; mapping a JPEG/WebP/JXL input's metadata into PNG chunks is a cross-format job of its +own. The libpng oracle reads no chunk back and drops warnings, so preservation is pinned against +gamut's own reader plus a decode the oracle accepts — #502, #571 and #572 are what would make it +differential. ## Efficiency (issue #224) @@ -302,7 +336,7 @@ byte) plus removing a sixth redundant filter pass per scanline. | 3 | Smallest lawful representation | **partial** — every reduction is implemented (grey, alpha-drop, ≤256 palette, 16→8, sub-byte, and a `tRNS` colour key for grey/truecolour) and 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. What is not done is the **selection**. `reduce::analyze8` still resolves *some* candidates on the raw estimate alone, and a raw estimate cannot see DEFLATE (below). Until the three-candidate race below it resolved all of them, and the eliminated runner-up was often the one that won the finished file: an opaque RGBA image with ≤256 colours kept an alpha channel that was 255 everywhere (349 bytes against 317), and a 16-bit image whose samples are all `k·257` kept all sixteen bits (220 against 172). The estimate now hands the best **chunk-free** candidate over beside the chunk-carrying one and `write_reduced_or_native` measures both, which closes that whole family — the chunk-free gates are mutually exclusive, so at most one such candidate ever exists. The remainder is the *pair* that both carry a chunk: where a palette and a `tRNS` colour key are both lawful, only the raw-smaller one is ever encoded. | | 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. 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. A tie keeps the **plain** encoding: cleaning buys its rewritten samples with a size win, and where there is no win there is nothing to buy them with. | -| 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] | +| 6 | Metadata hygiene | **preserve, never strip** — the encoder emits exactly what the caller set, and `gamut convert` carries a PNG input's metadata into a PNG output unless `--strip-metadata` asks otherwise (see [Metadata preservation](#metadata-preservation-issue-483)). Preserving costs bytes, and that is the trade this axis takes: a smaller file that silently lost a colour profile is not a better one. 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. | From beda77436b079ebc32b8aadfe5a33744d3122418 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 07:21:09 -0400 Subject: [PATCH 84/94] test(png): pin both readings of an XMP chunk's iTXt framing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `parse_itxt` binds §11.3.3.4's compression flag, language tag and translated keyword and now hands all three to `XmpFraming`. The integration suite pins the framed case; nothing pinned the unframed one, so a parser that reported every packet compressed, or that kept an empty tag as `Some("")`, would have rewritten a chunk conforming to §11.3.3.1 Table 21's recommended framing as something else with no test failing. Both directions are asserted here, inline, because `collect` is not public. --- crates/gamut-png/src/decoded.rs | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/crates/gamut-png/src/decoded.rs b/crates/gamut-png/src/decoded.rs index 220aeb0f..c9df2bc0 100644 --- a/crates/gamut-png/src/decoded.rs +++ b/crates/gamut-png/src/decoded.rs @@ -620,6 +620,38 @@ mod tests { assert!(meta.texts.is_empty()); } + /// The framing §11.3.3.1 Table 21 recommends — "Compression Flag set to 0, and both Language + /// Tag and Translated Keyword set to the null string" — reads back as exactly that, so a + /// re-encode reproduces it rather than inventing one. + /// + /// Kills the framing arm of [`parse_itxt`] read the other way from + /// `xmp_framing_carries_the_compression_flag`: a mutant that reports every packet compressed, + /// or that keeps an empty tag as `Some("")`, would rewrite a Table 21-conforming chunk as + /// something else. + #[test] + fn an_unframed_xmp_packet_reads_back_unframed() { + let itxt = b"XML:com.adobe.xmp\0\0\0\0\0"; + let meta = collect(&[(*b"iTXt", itxt)], 1024); + assert_eq!(meta.xmp_framing, Some(XmpFraming::default())); + } + + /// §11.3.3.4's compression flag, language tag and translated keyword belong to the XMP chunk + /// as much as to any other `iTXt`, and the packet's own field cannot hold them. Losing the + /// flag alone rewrites a compressed packet at many times its size. + /// + /// Kills each field of the `ITxt::Xmp` arm of [`parse_itxt`]. + #[test] + fn xmp_framing_carries_the_compression_flag() { + let mut itxt = b"XML:com.adobe.xmp\0\x01\0en-GB\0Metadata\0".to_vec(); + itxt.extend_from_slice(&deflated(b"")); + let meta = collect(&[(*b"iTXt", &itxt)], 1024); + assert_eq!(meta.xmp.as_deref(), Some(&b""[..])); + let framing = meta.xmp_framing.expect("framed"); + assert!(framing.compressed); + assert_eq!(framing.language.as_deref(), Some("en-GB")); + assert_eq!(framing.translated_keyword.as_deref(), Some("Metadata")); + } + #[test] fn metadata_budget_is_cumulative_and_skips_busting_chunks() { let body = vec![b'a'; 600]; From 64a61f49166064cec3dc9d2af2baffabddca9eb3 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 07:22:10 -0400 Subject: [PATCH 85/94] docs(png): say whose job the colour-chunk ranking is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `with_metadata` carries `cICP`, `iCCP` and `sRGB` together and justifies it with §4.3 Table 1's Color Chunk Priority — but Table 1 ranks the chunks for a *reader*, and which one to honour depends on whether that reader has a colour-management module. gamut-png's own reader surfaces all of them and ranks none, so the justification is a claim about other readers, not about this crate. Resolving a profile against a rendering intent is `gamut-cmm`'s work (epic #323), and this encoder deliberately does not pre-empt it. Refs #483. --- crates/gamut-png/STATUS.md | 6 ++++++ crates/gamut-png/src/encoder.rs | 5 ++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/gamut-png/STATUS.md b/crates/gamut-png/STATUS.md index 3c34b64a..7ffee8a5 100644 --- a/crates/gamut-png/STATUS.md +++ b/crates/gamut-png/STATUS.md @@ -188,6 +188,12 @@ libpng reads a file carrying both and returns the same pixels (`tests/oracle.rs` written: dropping either would throw away colour information the source carried, and a reader takes the one it can use. +That last clause is a claim about **other** readers, not about this crate. Table 1 ranks the chunks +for a reader, and which one to honour depends on whether the reader has a CMM at all — which an +encoder cannot know. gamut-png's own reader surfaces `cICP`, `iCCP`, `sRGB`, `cHRM` and `gAMA` side +by side and ranks none of them; resolving a profile against an intent is `gamut-cmm`'s work +(epic #323), and this encoder deliberately does not pre-empt it. + **Only the null byte refuses the encode. Everything else §11.3.3 asks for is a notice.** §15 gives the BCP 14 keywords force "when, and only when, they appear in all capitals", and every statement §11.3.3.1 makes about a keyword's shape is lowercase — "Keywords shall contain only diff --git a/crates/gamut-png/src/encoder.rs b/crates/gamut-png/src/encoder.rs index 1484d879..18c7041c 100644 --- a/crates/gamut-png/src/encoder.rs +++ b/crates/gamut-png/src/encoder.rs @@ -568,7 +568,10 @@ impl PngEncoder { /// /// Everything the read side surfaces is set, including a `cICP`, an `sRGB` and an `iCCP` /// together — §4.3 Table 1 ranks the colour chunks precisely so a file may carry more than - /// one, and a reader honours the lowest priority number. Each text annotation goes back into + /// one, and a reader honours the lowest priority number. That is a claim about *other* + /// readers: this crate's own reader surfaces all of them and ranks none, because which chunk + /// to honour depends on whether the reader has a colour-management module, which an encoder + /// cannot know. Resolving a profile against an intent belongs to `gamut-cmm`. Each text annotation goes back into /// the chunk it came out of, compressed if it was compressed /// ([`TextChunkKind`](crate::TextChunkKind)); so does the XMP packet, whose own framing — /// compression flag, language tag, translated keyword — rides in From 9243d2e41389e693583830acbeee3786b0677da1 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 07:24:44 -0400 Subject: [PATCH 86/94] docs(png): cite the sections the vendored spec actually numbers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three chunk citations on the read surface named the wrong clause, checked against `references/png/png-3.html`: cICP is §11.3.2.6 (§11.3.2.5 is sRGB), sRGB is §11.3.2.5 (§11.3.2.4 is sBIT), and eXIf is §11.3.4.5 (§11.3.4.4 is sPLT). A reader following one of these lands on a different chunk's clause, which is worse than no citation at all in a crate whose rule is that the specification is the source of truth. The crate also cites tRNS as §11.3.2.1 in six files, where the vendored text numbers it §11.3.1.1 and gives §11.3.2.1 to cHRM. That is outside this change's surface and is filed separately. Refs #483. --- crates/gamut-png/src/decoded.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/gamut-png/src/decoded.rs b/crates/gamut-png/src/decoded.rs index c9df2bc0..5b93a061 100644 --- a/crates/gamut-png/src/decoded.rs +++ b/crates/gamut-png/src/decoded.rs @@ -89,7 +89,7 @@ pub struct Chromaticities { pub blue: (u32, u32), } -/// Coding-independent code points (cICP, §11.3.2.5) identifying the video-signal colour space. +/// Coding-independent code points (cICP, §11.3.2.6) identifying the video-signal colour space. #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[non_exhaustive] pub struct Cicp { @@ -188,7 +188,7 @@ pub struct DecodedPng { pub palette: Option, /// The tRNS colour key of a greyscale/truecolour image, in native (unscaled) sample units. pub transparency: Option, - /// The eXIf payload verbatim: a TIFF stream starting with `II`/`MM` (§11.3.4.4). Feed as + /// The eXIf payload verbatim: a TIFF stream starting with `II`/`MM` (§11.3.4.5). Feed as /// `gamut_metadata::MetadataBlock::Exif`. pub exif: Option>, /// The embedded ICC profile (iCCP), inflated. Feed as `MetadataBlock::Icc`. @@ -224,7 +224,7 @@ pub struct DecodedPng { pub gamma: Option, /// cHRM chromaticities, each coordinate × 100 000. pub chromaticities: Option, - /// sRGB rendering intent (§11.3.2.4). + /// sRGB rendering intent (§11.3.2.5). pub srgb: Option, /// cICP video-signal code points. pub cicp: Option, @@ -267,7 +267,7 @@ pub struct DecodedPng { #[derive(Debug, Clone, Default, PartialEq, Eq)] #[non_exhaustive] pub struct PngMetadata { - /// The eXIf payload verbatim: a TIFF stream starting with `II`/`MM` (§11.3.4.4). Feed as + /// The eXIf payload verbatim: a TIFF stream starting with `II`/`MM` (§11.3.4.5). Feed as /// `gamut_metadata::MetadataBlock::Exif`. pub exif: Option>, /// The embedded ICC profile (iCCP), inflated. Feed as `MetadataBlock::Icc`. @@ -303,7 +303,7 @@ pub struct PngMetadata { pub gamma: Option, /// cHRM chromaticities, each coordinate × 100 000. pub chromaticities: Option, - /// sRGB rendering intent (§11.3.2.4). + /// sRGB rendering intent (§11.3.2.5). pub srgb: Option, /// cICP video-signal code points. pub cicp: Option, From cbf3e02e53175a72f7591a1cc782635d773a7254 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 08:44:20 -0400 Subject: [PATCH 87/94] refactor(png): give the trailing-opaque tRNS trim one owner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rule that a tRNS chunk may omit its trailing opaque entries (§11.3.2.1) lived inside reduce.rs's build_indexed, where only the encoder-derived palette could reach it. It is a fact about tRNS, not about that path, and a second palette path is about to need it. Move it to palette.rs as trim_trailing_opaque, beside the OPAQUE constant that names the value the rule is about, and have build_indexed call it. Behaviour is unchanged; the loop is the same loop. --- crates/gamut-png/src/palette.rs | 38 +++++++++++++++++++++++++++++++++ crates/gamut-png/src/reduce.rs | 7 +++--- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/crates/gamut-png/src/palette.rs b/crates/gamut-png/src/palette.rs index 4874b5cc..7aa15418 100644 --- a/crates/gamut-png/src/palette.rs +++ b/crates/gamut-png/src/palette.rs @@ -119,6 +119,22 @@ impl PngPalette { } } +/// The alpha a palette entry has when `tRNS` does not carry one for it (§11.3.2.1). +pub(crate) const OPAQUE: u8 = 255; + +/// Drops the trailing fully-opaque entries a `tRNS` chunk is allowed to omit: a decoder reads +/// every entry past the chunk's end as opaque (§11.3.2.1), so those bytes say nothing the absence +/// of the bytes does not already say. +/// +/// One owner for the rule, because both palette paths need it and a rule restated twice is a rule +/// that can drift: the encoder-derived palette trims the alphas it collects +/// ([`crate::reduce`]), and a caller-supplied one will trim its own. +pub(crate) fn trim_trailing_opaque(alphas: &mut Vec) { + while alphas.last() == Some(&OPAQUE) { + alphas.pop(); + } +} + #[cfg(test)] mod tests { @@ -177,6 +193,28 @@ mod tests { assert!(!PngPalette::new(&[[0, 0, 0]]).unwrap().has_transparency()); } + /// [`trim_trailing_opaque`] removes exactly the run of opaque entries at the end. + /// + /// It stops at the last non-opaque entry rather than removing every opaque one, because a + /// `tRNS` chunk is positional: entry 1 below is opaque and has to stay, or entry 2's alpha + /// would land on entry 1. + #[test] + fn trailing_opaque_alphas_are_the_ones_trns_may_omit() { + let mut alphas = vec![0, OPAQUE, 128, OPAQUE, OPAQUE]; + trim_trailing_opaque(&mut alphas); + assert_eq!(alphas, vec![0, OPAQUE, 128]); + + // A wholly opaque run leaves nothing, which is the chunk not being written at all. + let mut all_opaque = vec![OPAQUE; 4]; + trim_trailing_opaque(&mut all_opaque); + assert!(all_opaque.is_empty()); + + // Nothing to trim is not an error. + let mut none = vec![7, 8]; + trim_trailing_opaque(&mut none); + assert_eq!(none, vec![7, 8]); + } + #[test] fn from_chunks_rejects_malformed_payloads() { assert!(PngPalette::from_chunks(&[1, 2, 3, 4], None).is_err()); // not a triple multiple diff --git a/crates/gamut-png/src/reduce.rs b/crates/gamut-png/src/reduce.rs index 6bfef424..ab62eb1e 100644 --- a/crates/gamut-png/src/reduce.rs +++ b/crates/gamut-png/src/reduce.rs @@ -19,6 +19,7 @@ use std::collections::HashMap; use std::collections::hash_map::Entry; use crate::pack::gray8_scale; +use crate::palette; /// A chosen reduced encoding for an image. pub enum Reduced { @@ -550,7 +551,7 @@ fn build_indexed( }) .collect(); 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 trns = if ordered.iter().any(|c| c[3] != palette::OPAQUE) { 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. @@ -561,9 +562,7 @@ fn build_indexed( // the loop stops with at least one element left. The `alphas.len() > 1` that used to be // here therefore decided nothing, and `>` vs `>=` was an equivalent mutant no test could // kill (#110) -- removed rather than excluded. - while alphas.last() == Some(&255) { - alphas.pop(); - } + palette::trim_trailing_opaque(&mut alphas); Some(alphas) } else { None From 1f3ef91b7ba0c1bf7f7db4f5fc5287f190ecd6c1 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 09:33:28 -0400 Subject: [PATCH 88/94] feat(png): clean a caller-supplied palette before writing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `encode_indexed8` wrote the caller's palette verbatim. Unlike the palette `reduce.rs` builds, which cannot hold either by construction, a caller's may hold entries nothing in the file names and entries naming a colour an earlier entry already names. Both go into an incompressible `PLTE`, and the count of them picks the index bit depth -- a 256-entry palette holding four colours cost 768 `PLTE` bytes and pinned every pixel to 8 bits where 2 would do. `PngPalette::cleaned` drops an entry no pixel and no in-range `bKGD` index marks, merges a later entry holding the same RGB *and* the same alpha as an earlier one, trims the trailing opaque `tRNS` bytes §11.3.2.1 lets a chunk omit, and returns the old-index -> new-index map. `encode_indexed8` derives the depth from what survives, remaps the image's indices, and moves a `bKGD` palette index with the entry it names, so the background still resolves to the colour the caller chose. An entry named only by that background survives with it; an index already out of range stays out of range rather than being renumbered back in. Alpha is part of an entry's identity: two entries sharing an RGB triple but not an alpha are different colours and both survive, and an entry `tRNS` omits compares as opaque rather than as absent. Surviving entries keep the caller's relative order -- reordering is a heuristic question, filed as #612. Nothing is reported, because nothing is observable: every surviving entry keeps its bytes and the map sends each old index to the entry holding the colour it named. libpng resolving a wholly redundant palette to the caller's exact RGBA is the test of that, rather than a round trip through our own decoder, which would resolve the file through the very palette the encoder wrote. Refs #482 --- crates/gamut-png/src/encoder.rs | 183 +++++++++++++++++++++++++++- crates/gamut-png/src/palette.rs | 140 ++++++++++++++++++++- crates/gamut-png/tests/oracle.rs | 56 +++++++++ crates/gamut-png/tests/roundtrip.rs | 6 +- 4 files changed, 380 insertions(+), 5 deletions(-) diff --git a/crates/gamut-png/src/encoder.rs b/crates/gamut-png/src/encoder.rs index 18c7041c..cc0cf94c 100644 --- a/crates/gamut-png/src/encoder.rs +++ b/crates/gamut-png/src/encoder.rs @@ -465,6 +465,8 @@ impl PngEncoder { /// /// 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). + /// It keeps naming that entry: cleaning the palette may renumber it, and the chunk is + /// renumbered with it, so the background written is the colour you pointed at. /// 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)) @@ -844,6 +846,19 @@ impl PngEncoder { /// Encodes an 8-bit indexed (palette) image. Indexed colour does not fit the single-buffer /// [`EncodeImage`] shape because it needs a separate palette, so it is an inherent method. /// + /// `palette` is **cleaned** before it is written, silently and losslessly: an entry nothing in + /// the file names is dropped, a second entry holding the same RGB *and* alpha as an earlier one + /// is merged into it, the trailing opaque `tRNS` bytes §11.3.2.1 lets a chunk omit are omitted, + /// the index bit depth is derived from what survives, and the image's indices — and a + /// [`with_background_index`](Self::with_background_index) background — are renumbered to match + /// ([`PngPalette::cleaned`]). The colour every pixel resolves to is unchanged, which is why + /// this reports nothing: a merged entry did not fail to come along, it arrived under another + /// index. Surviving entries keep the order you gave them. + /// + /// What it costs is bounded by the palette, not by the picture: at most 256 entries, each + /// compared against the survivors before it. Only the index remap walks the image, one pass + /// and one byte per pixel, beside the filter candidates the encoder already deflates. + /// /// # Errors /// /// Returns [`Error::InvalidInput`] if any index is out of range for `palette`. @@ -861,20 +876,49 @@ impl PngEncoder { "PNG: palette index out of range", )); } + // Every entry the finished file still has to name. A pixel names one; so does an in-range + // `bKGD` palette index, which is the one background form that survives this path + // (`ancillary::bkgd_for`), so the entry behind it has to survive with it. + let mut used = [false; 256]; + for &index in indices { + used[usize::from(index)] = true; + } + let background = match self.ancillary.bkgd.as_deref() { + Some(&[index]) if usize::from(index) < palette.len() => Some(index), + _ => None, + }; + if let Some(index) = background { + used[usize::from(index)] = true; + } + let (palette, remap) = palette.cleaned(&used); + let indices: Vec = indices.iter().map(|&i| remap[usize::from(i)]).collect(); + // The background still names the colour the caller chose, so its index moves with the + // entry. Only a background that actually moved copies the encoder's chunk state. + let renumbered; + let this = match background.filter(|&index| remap[usize::from(index)] != index) { + Some(index) => { + renumbered = self + .clone() + .with_background_index(remap[usize::from(index)]); + &renumbered + } + None => self, + }; + let dims = image.dimensions(); // Use the smallest bit depth that holds every index — a free, lossless space win. let depth = reduce::index_bit_depth(palette.len()); let packed; let sample_bytes = if depth < 8 { packed = - pack::pack_scanlines(indices, dims.width as usize, dims.height as usize, depth); + pack::pack_scanlines(&indices, dims.width as usize, dims.height as usize, depth); packed.as_slice() } else { - indices + indices.as_slice() }; let plte = palette.plte(); let trns = palette.trns(); - self.write_png( + this.write_png( (dims.width, dims.height), sample_bytes, WrittenHeader { @@ -1623,6 +1667,139 @@ mod tests { assert_eq!(find_chunk(&png, b"bKGD"), Some(vec![7])); } + /// The written index depth is derived from the palette *after* cleaning, so an oversized + /// caller palette does not pin the file to 8-bit indices. + /// + /// This is where the saving actually is. Dropping 253 unnamed entries is 759 `PLTE` bytes; + /// dropping the depth they forced is three quarters of every pixel. The one reason it fails is + /// that `encode_indexed8` measured the caller's entry count instead of the cleaned one. + /// + /// Which count maps to which depth is [`reduce::index_bit_depth`]'s own fact, pinned by + /// `tests/oracle.rs::indexed_uses_minimal_bit_depth` against libpng; this asserts only that + /// the cleaned count is what reaches it. Every case is a depth below 8, so a caller palette + /// that stayed uncleaned could not produce it. + #[test] + fn the_index_depth_follows_the_cleaned_entry_count() { + for distinct in [1usize, 2, 3, 4, 5, 16] { + // 256 entries, of which the image names the first `distinct`. + let entries: Vec<[u8; 3]> = (0..256u32).map(|i| [i as u8, 0, 0]).collect(); + let palette = PngPalette::new(&entries).unwrap(); + let indices: Vec = (0..64usize).map(|i| (i % distinct) as u8).collect(); + let img = ImageRef::::new(&indices, Dimensions::new(64, 1).unwrap()).unwrap(); + let mut png = Vec::new(); + PngEncoder::new() + .encode_indexed8(img, &palette, &mut png) + .unwrap(); + + let expected = reduce::index_bit_depth(distinct); + assert!(expected < 8, "{distinct}: the fixture must be able to tell"); + assert_eq!(png[24], expected, "{distinct} entries named"); + assert_eq!( + find_chunk(&png, b"PLTE").map(|plte| plte.len()), + Some(distinct * 3), + "{distinct} entries named" + ); + } + } + + /// A caller palette padded with redundancy produces the *same file*, byte for byte, as the + /// tight palette holding the same colours. + /// + /// The whole feature in one equality, and it fails for one reason: what was written was not + /// the tight palette. It is stronger than counting `PLTE` bytes because it also fixes the + /// order the survivors are written in and the length of the `tRNS` chunk beside them — a clean + /// that dropped and merged correctly but reordered, or left a trailing opaque alpha, produces + /// a different file and is caught here. + /// + /// The sizes are the ones `STATUS.md` publishes: 1 194 bytes before this clean existed, 162 + /// after, against 164/162 for the tight palette. + #[test] + fn a_redundant_palette_costs_what_the_tight_one_costs() { + let (w, h) = (64u32, 64u32); + let colours: [[u8; 3]; 4] = [[240, 90, 40], [30, 30, 60], [255, 255, 255], [10, 200, 120]]; + let alphas: [u8; 4] = [255, 0, 255, 255]; + // 256 entries holding those four colours, 64 times over. + let padded: Vec<[u8; 3]> = (0..256).map(|i| colours[i % 4]).collect(); + let padded_alpha: Vec = (0..256).map(|i| alphas[i % 4]).collect(); + let padded = PngPalette::with_transparency(&padded, &padded_alpha).unwrap(); + let tight = PngPalette::with_transparency(&colours, &alphas).unwrap(); + + let indices: Vec = (0..(w * h) as usize) + .map(|i| (((i as u32 % w) / 7 + (i as u32 / w) / 5) % 4) as u8) + .collect(); + let encode = |palette: &PngPalette| { + let img = ImageRef::::new(&indices, Dimensions::new(w, h).unwrap()).unwrap(); + let mut png = Vec::new(); + PngEncoder::new() + .encode_indexed8(img, palette, &mut png) + .unwrap(); + png + }; + + assert_eq!(encode(&padded), encode(&tight)); + assert_eq!(encode(&tight).len(), 162, "the size STATUS.md publishes"); + } + + /// A `bKGD` palette index still names the colour the caller chose after cleaning renumbers the + /// palette — and the entry it names survives even when no pixel names it. + /// + /// Both halves are the same claim, and it fails for one reason: the background stopped meaning + /// what the caller said. The chunk is kept verbatim on this path + /// (`ancillary::bkgd_for`, [`PaletteOrigin::Caller`]), so an index left pointing into the + /// caller's numbering would silently repaint the background — or, if its entry were dropped, + /// name a colour the file no longer holds. + #[test] + fn a_background_index_names_a_surviving_entry_after_cleaning() { + let palette = + PngPalette::new(&[[0, 0, 0], [1, 1, 1], [2, 2, 2], [3, 3, 3], [4, 4, 4]]).unwrap(); + // Only entry 1 is painted; entry 3 is named by the background alone. + let indices = vec![1u8; 8]; + let img = ImageRef::::new(&indices, Dimensions::new(8, 1).unwrap()).unwrap(); + let mut png = Vec::new(); + PngEncoder::new() + .with_background_index(3) + .encode_indexed8(img, &palette, &mut png) + .unwrap(); + + assert_eq!( + find_chunk(&png, b"PLTE"), + Some(vec![1, 1, 1, 3, 3, 3]), + "the background's entry is kept, in the caller's order" + ); + assert_eq!( + find_chunk(&png, b"bKGD"), + Some(vec![1]), + "and the index moved with it" + ); + } + + /// A `bKGD` index past the end of the caller's palette is still omitted, not renumbered into + /// range. + /// + /// Cleaning maps an index it never marked to 0, which is a *valid* entry, so an out-of-range + /// index that reached the renumbering would come back in range and be written — turning a + /// chunk `ancillary::bkgd_for` deliberately drops into a background the caller never asked + /// for. + /// + /// The first case is `palette.len()` itself, the smallest index that is out of range: the + /// range test is `<`, and the off-by-one that makes it `<=` is invisible to any index further + /// out. + #[test] + fn a_background_index_past_the_palette_stays_omitted() { + let palette = PngPalette::new(&[[9, 8, 7], [6, 5, 4], [3, 2, 1]]).unwrap(); + for index in [3u8, 4, 200, 255] { + let indices = vec![0u8, 1, 2, 1]; + let img = ImageRef::::new(&indices, Dimensions::new(4, 1).unwrap()).unwrap(); + let mut png = Vec::new(); + PngEncoder::new() + .with_background_index(index) + .encode_indexed8(img, &palette, &mut png) + .unwrap(); + + assert_eq!(find_chunk(&png, b"bKGD"), None, "background index {index}"); + } + } + /// An indexed image needing 8-bit indices is written a byte per pixel, not bit-packed. /// /// The packing branch is gated on `depth < 8`. Every indexed fixture had at most 16 colours, diff --git a/crates/gamut-png/src/palette.rs b/crates/gamut-png/src/palette.rs index 7aa15418..577b532c 100644 --- a/crates/gamut-png/src/palette.rs +++ b/crates/gamut-png/src/palette.rs @@ -104,6 +104,58 @@ impl PngPalette { self.rgb.is_empty() } + /// The palette reduced to the entries `used` marks, with duplicates merged and the `tRNS` + /// trailing-opaque bytes trimmed, plus the old-index → new-index map that rewrites an image's + /// indices onto it. + /// + /// Three redundancies a caller-supplied palette may carry that one built from the pixels + /// cannot, and what each costs the file: + /// + /// - an entry `used` does not mark: 3 `PLTE` bytes naming a colour nothing in the file reads; + /// - a later entry with the same RGB **and** the same alpha as an earlier one: the same 3 + /// bytes, for a colour the earlier entry already names; + /// - a trailing opaque `tRNS` entry: 1 byte the chunk may simply not carry (§11.3.2.1). + /// + /// The saving is rarely those bytes. It is that `PLTE` is incompressible and that a shorter + /// palette may fit a smaller index bit depth — a 256-entry palette holding three colours drops + /// 759 `PLTE` bytes *and* takes the index stream from 8 bits per pixel to 2. + /// + /// Nothing is lost: every surviving entry keeps its RGB and its alpha byte for byte, and the + /// map sends each marked old index to the entry that holds the colour it named, so the pixels + /// a decoder resolves are the ones the caller supplied. Surviving entries keep the caller's + /// relative order — reordering them is a separate, heuristic question (#612). + /// + /// `used[i]` beyond [`Self::len`] is ignored. `remap[i]` for an unmarked or out-of-range `i` + /// is 0, which no index reaching a remapped image can be, because the caller marks every index + /// its image uses. + /// + /// The result is a valid palette without a length check: at most 256 entries go in so at most + /// 256 come out, and the one caller ([`crate::PngEncoder::encode_indexed8`]) marks the index + /// of every pixel in an image that [`gamut_core::ImageRef`] has already refused to build empty, + /// so at least one entry is always marked. + pub(crate) fn cleaned(&self, used: &[bool; 256]) -> (Self, [u8; 256]) { + let mut kept: Vec<([u8; 3], u8)> = Vec::new(); + let mut remap = [0u8; 256]; + for (index, &rgb) in self.rgb.iter().enumerate() { + if !used[index] { + continue; + } + let entry = (rgb, self.alpha.get(index).copied().unwrap_or(OPAQUE)); + let position = kept.iter().position(|&k| k == entry).unwrap_or_else(|| { + kept.push(entry); + kept.len() - 1 + }); + remap[index] = position as u8; + } + let mut alpha: Vec = kept.iter().map(|&(_, a)| a).collect(); + trim_trailing_opaque(&mut alpha); + let cleaned = Self { + rgb: kept.into_iter().map(|(rgb, _)| rgb).collect(), + alpha, + }; + (cleaned, remap) + } + /// The PLTE chunk payload: RGB triples, flattened. pub(crate) fn plte(&self) -> Vec { self.rgb.iter().flatten().copied().collect() @@ -128,7 +180,7 @@ pub(crate) const OPAQUE: u8 = 255; /// /// One owner for the rule, because both palette paths need it and a rule restated twice is a rule /// that can drift: the encoder-derived palette trims the alphas it collects -/// ([`crate::reduce`]), and a caller-supplied one will trim its own. +/// ([`crate::reduce`]), and a caller-supplied one trims [`PngPalette::cleaned`]'s. pub(crate) fn trim_trailing_opaque(alphas: &mut Vec) { while alphas.last() == Some(&OPAQUE) { alphas.pop(); @@ -193,6 +245,15 @@ mod tests { assert!(!PngPalette::new(&[[0, 0, 0]]).unwrap().has_transparency()); } + /// Marks `indices` (and nothing else) as used, the way `encode_indexed8` does. + fn used_by(indices: &[u8]) -> [bool; 256] { + let mut used = [false; 256]; + for &index in indices { + used[usize::from(index)] = true; + } + used + } + /// [`trim_trailing_opaque`] removes exactly the run of opaque entries at the end. /// /// It stops at the last non-opaque entry rather than removing every opaque one, because a @@ -215,6 +276,83 @@ mod tests { assert_eq!(none, vec![7, 8]); } + /// [`PngPalette::cleaned`] drops an entry no index marks, and renumbers the survivors. + #[test] + fn cleaning_drops_an_entry_no_index_marks() { + let palette = PngPalette::new(&[[0, 0, 0], [1, 1, 1], [2, 2, 2], [3, 3, 3]]).unwrap(); + let (cleaned, remap) = palette.cleaned(&used_by(&[1, 3])); + + assert_eq!(cleaned.len(), 2); + assert_eq!(cleaned.rgb(0), Some([1, 1, 1])); + assert_eq!(cleaned.rgb(1), Some([3, 3, 3])); + // The survivors keep the caller's relative order, so 1 lands before 3. + assert_eq!(remap[1], 0); + assert_eq!(remap[3], 1); + } + + /// [`PngPalette::cleaned`] merges two entries naming the same colour, sending both old indices + /// to the surviving one. + #[test] + fn cleaning_merges_entries_that_name_the_same_colour() { + let palette = PngPalette::new(&[[9, 9, 9], [4, 4, 4], [9, 9, 9]]).unwrap(); + let (cleaned, remap) = palette.cleaned(&used_by(&[0, 1, 2])); + + assert_eq!(cleaned.len(), 2, "the repeated colour is written once"); + assert_eq!(cleaned.rgb(0), Some([9, 9, 9])); + assert_eq!(cleaned.rgb(1), Some([4, 4, 4])); + assert_eq!( + remap[2], remap[0], + "the duplicate resolves to the first entry" + ); + assert_eq!(remap[1], 1); + } + + /// Two entries with the same RGB but different alphas are different colours, so + /// [`PngPalette::cleaned`] keeps both. + /// + /// Merging them would silently repaint every pixel using one of them. + #[test] + fn an_entry_is_its_alpha_as_well_as_its_rgb() { + let palette = PngPalette::with_transparency(&[[9, 9, 9], [9, 9, 9]], &[0, 200]).unwrap(); + let (cleaned, remap) = palette.cleaned(&used_by(&[0, 1])); + + assert_eq!(cleaned.len(), 2); + assert_eq!(cleaned.alpha(0), Some(0)); + assert_eq!(cleaned.alpha(1), Some(200)); + assert_ne!(remap[0], remap[1]); + } + + /// An entry the palette leaves out of `tRNS` is opaque (§11.3.2.1), so + /// [`PngPalette::cleaned`] must compare it as opaque rather than as absent. + /// + /// Entry 1 below carries an explicit `OPAQUE` and entry 2 carries none; they are the same + /// colour and must merge, which they cannot if a missing alpha is treated as its own value. + #[test] + fn a_missing_trns_entry_is_opaque_when_entries_are_compared() { + let palette = + PngPalette::with_transparency(&[[0, 0, 0], [5, 5, 5], [5, 5, 5]], &[0, OPAQUE]) + .unwrap(); + let (cleaned, remap) = palette.cleaned(&used_by(&[0, 1, 2])); + + assert_eq!(cleaned.len(), 2); + assert_eq!(remap[2], remap[1]); + } + + /// [`PngPalette::cleaned`] hands back a palette whose `tRNS` payload is already trimmed, so + /// the encoder writes the shortest chunk §11.3.2.1 allows. + #[test] + fn a_cleaned_palette_carries_a_trimmed_trns() { + let palette = + PngPalette::with_transparency(&[[0, 0, 0], [1, 1, 1], [2, 2, 2]], &[0, OPAQUE, OPAQUE]) + .unwrap(); + let (cleaned, _) = palette.cleaned(&used_by(&[0, 1, 2])); + assert_eq!(cleaned.trns(), Some(&[0u8][..])); + + // Nothing transparent survives: no chunk at all. + let (opaque, _) = palette.cleaned(&used_by(&[1, 2])); + assert_eq!(opaque.trns(), None); + } + #[test] fn from_chunks_rejects_malformed_payloads() { assert!(PngPalette::from_chunks(&[1, 2, 3, 4], None).is_err()); // not a triple multiple diff --git a/crates/gamut-png/tests/oracle.rs b/crates/gamut-png/tests/oracle.rs index fe6d2a57..1e632b17 100644 --- a/crates/gamut-png/tests/oracle.rs +++ b/crates/gamut-png/tests/oracle.rs @@ -235,6 +235,62 @@ fn indexed8_with_palette_and_transparency_round_trips() { assert_eq!(rgba, expected); } +/// A caller palette full of redundancy still resolves, in libpng, to exactly the colours the +/// caller supplied. +/// +/// `encode_indexed8` cleans the palette it is handed — dropping entries nothing names, merging +/// entries that name the same colour, renumbering the indices onto the result — and reports +/// nothing, because none of it is supposed to be observable. This is the test of that claim, and +/// it fails for one reason: cleaning changed what a pixel means. A merged pair that were not the +/// same colour, an entry dropped while something still named it, or a remap pointing at the wrong +/// survivor all land here as the wrong RGBA. +/// +/// libpng rather than our own decoder, because our decoder would resolve the file through the very +/// palette the encoder wrote: a wrong palette and a matching wrong remap agree with each other, +/// and a round trip cannot see a defect that is symmetric across the two. +#[test] +fn a_cleaned_palette_still_resolves_to_the_colours_the_caller_supplied() { + // Three colours spread over all 256 entries, so 253 entries are redundant: two of the three + // are named by 85 entries each and repeat every third index. The third pairs an RGB triple + // that also occurs opaque with alpha 0, so a merge that ignored alpha would collapse two + // colours the caller kept apart. + let colours: [([u8; 3], u8); 3] = [ + ([200, 10, 10], 255), + ([10, 200, 10], 0), + ([200, 10, 10], 64), + ]; + let rgb: Vec<[u8; 3]> = (0..256).map(|i| colours[i % 3].0).collect(); + let alpha: Vec = (0..256).map(|i| colours[i % 3].1).collect(); + let palette = PngPalette::with_transparency(&rgb, &alpha).unwrap(); + + let (w, h) = (16u32, 16u32); + let indices: Vec = (0..(w * h) as usize).map(|i| i as u8).collect(); + let mut png = Vec::new(); + PngEncoder::new() + .encode_indexed8( + ImageRef::::new(&indices, Dimensions::new(w, h).unwrap()).unwrap(), + &palette, + &mut png, + ) + .expect("encode"); + + let (dw, dh, rgba) = libpng_oracle::decode_rgba8(&png); + assert_eq!((dw, dh), (w, h)); + let expected: Vec = indices + .iter() + .flat_map(|&index| { + let ([r, g, b], a) = colours[usize::from(index) % 3]; + [r, g, b, a] + }) + .collect(); + assert_eq!(rgba, expected); + assert_eq!( + libpng_oracle::decode(&png).color_type, + libpng_oracle::COLOR_PALETTE, + "still an indexed file, so the palette is what resolved it" + ); +} + #[test] fn indexed8_rejects_out_of_range_index() { let palette = PngPalette::new(&[[0, 0, 0], [255, 255, 255]]).unwrap(); diff --git a/crates/gamut-png/tests/roundtrip.rs b/crates/gamut-png/tests/roundtrip.rs index f49aa436..82260679 100644 --- a/crates/gamut-png/tests/roundtrip.rs +++ b/crates/gamut-png/tests/roundtrip.rs @@ -120,7 +120,11 @@ fn indexed_round_trips_at_every_auto_depth() { .collect(); let alpha: Vec = (0..entries.min(5)).map(|i| (i * 60) as u8).collect(); let palette = PngPalette::with_transparency(&rgb, &alpha).unwrap(); - let (w, h) = (21u32, 9u32); + // At least 256 pixels, so the cycling indices below name every entry of even the largest + // palette -- `encode_indexed8` drops an entry no pixel names, which would otherwise make + // this test about palette cleaning instead of about bit depth. An odd width keeps the + // sub-byte depths padding their rows. + let (w, h) = (23u32, 13u32); let indices: Vec = (0..(w * h) as usize).map(|i| (i % entries) as u8).collect(); let mut png = Vec::new(); PngEncoder::new() From a7737fc3774e8c5298b99f68c6d9502b558190a1 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 09:33:43 -0400 Subject: [PATCH 89/94] docs(png): record what cleaning a caller's palette buys Axis 4 said caller-supplied palette cleanup remained; it no longer does. Give it its own section beside the cost model: what is dropped, merged, trimmed and renumbered, that the `bKGD` index moves with its entry, and that the whole pass is silent because it is lossless. The measurement is a 64x64 four-colour picture handed a full 256-entry palette: 1194 bytes before, 162 after, against 164/162 for the tight palette holding the same four colours. Both "before" figures are measured on this branch's base. The two "after" figures are equal because after cleaning the two palettes *are* the same palette, which the encoder suite pins as a byte-for-byte file equality rather than as a size; the tight palette's own 2 bytes are the `tRNS` trim this path did not previously apply. The remainder axis 4 still names is ordering -- modified-Zeng for the derived palette, and any ordering of a caller's -- which is a heuristic chosen by measurement rather than a rule the specification states. Point it at #612, which holds that question, instead of at the umbrella issue. Refs #482 --- crates/gamut-png/STATUS.md | 43 ++++++++++++++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/crates/gamut-png/STATUS.md b/crates/gamut-png/STATUS.md index 7ffee8a5..087ee432 100644 --- a/crates/gamut-png/STATUS.md +++ b/crates/gamut-png/STATUS.md @@ -340,7 +340,7 @@ byte) plus removing a sixth redundant filter pass per scanline. | 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 | **partial** — every reduction is implemented (grey, alpha-drop, ≤256 palette, 16→8, sub-byte, and a `tRNS` colour key for grey/truecolour) and 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. What is not done is the **selection**. `reduce::analyze8` still resolves *some* candidates on the raw estimate alone, and a raw estimate cannot see DEFLATE (below). Until the three-candidate race below it resolved all of them, and the eliminated runner-up was often the one that won the finished file: an opaque RGBA image with ≤256 colours kept an alpha channel that was 255 everywhere (349 bytes against 317), and a 16-bit image whose samples are all `k·257` kept all sixteen bits (220 against 172). The estimate now hands the best **chunk-free** candidate over beside the chunk-carrying one and `write_reduced_or_native` measures both, which closes that whole family — the chunk-free gates are mutually exclusive, so at most one such candidate ever exists. The remainder is the *pair* that both carry a chunk: where a palette and a `tRNS` colour key are both lawful, only the raw-smaller one is ever encoded. | -| 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] | +| 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`. A **caller-supplied** palette is now cleaned as well (see [Cleaning a caller's palette](#cleaning-a-callers-palette)), so the two paths cost the same for the same picture. Modified-Zeng ordering — and any ordering of a caller's palette — remain, as a measured heuristic rather than a rule the spec states. [#612] | | 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. A tie keeps the **plain** encoding: cleaning buys its rewritten samples with a size win, and where there is no win there is nothing to buy them with. | | 6 | Metadata hygiene | **preserve, never strip** — the encoder emits exactly what the caller set, and `gamut convert` carries a PNG input's metadata into a PNG output unless `--strip-metadata` asks otherwise (see [Metadata preservation](#metadata-preservation-issue-483)). Preserving costs bytes, and that is the trade this axis takes: a smaller file that silently lost a colour profile is not a better one. 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. | @@ -403,9 +403,43 @@ palette becomes the index of its entry (an opaque entry where a transparent twin 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]. +under an encoder-derived palette it names nothing and is omitted. On that path the index is +renumbered with the entry it names when cleaning renumbers the palette, and the entry it names is +kept even when no pixel names it — the chunk is carried verbatim, so the alternative is a +background silently repainted. 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]. + +### Cleaning a caller's palette + +`encode_indexed8` takes the palette the caller hands it. That palette is not built from the pixels, +so — unlike the encoder-derived one, which cannot contain either by construction — it may hold +entries nothing names and entries that name a colour another entry already names. Both are written +into an incompressible `PLTE`, and the count of them decides the index bit depth. + +So the palette is cleaned before it is written (`PngPalette::cleaned`): an entry no pixel and no +`bKGD` index names is dropped, a later entry with the same RGB **and** the same alpha as an earlier +one is merged into it, the trailing opaque `tRNS` bytes §11.3.2.1 lets a chunk omit are omitted, the +index bit depth is derived from what survives, and the image's indices — and a +`with_background_index` background — are renumbered onto the result. Surviving entries keep the +caller's relative order; **ordering** a caller's palette is a separate, heuristic question ([#612]). + +It is silent and lossless, which is why it goes through no notice channel: a merged entry did not +fail to come along, it arrived under another index. libpng resolving the file to the caller's exact +RGBA is the test of that (`tests/oracle.rs`). + +Measured on a 64×64 four-colour picture handed a full 256-entry palette (4 colours repeated 64 +times, one of them transparent): + +| palette handed in | before | after | +| --- | ---: | ---: | +| 256 entries, 4 colours | 1 194 | **162** | +| 4 entries, tight | 164 | **162** | + +The redundant palette now costs exactly what the tight one costs — the files are byte-identical, +which `a_redundant_palette_costs_what_the_tight_one_costs` pins — because after cleaning they *are* +the same palette: −86.4% on the first row. The tight palette's own 2 bytes are the `tRNS` trim, +which this path did not previously apply. What is bought is rarely the `PLTE` bytes alone: 252 +dropped entries also take the index stream from 8 bits per pixel to 2. [#437]: https://github.com/visualcommons/gamut/issues/437 [#478]: https://github.com/visualcommons/gamut/issues/478 @@ -416,3 +450,4 @@ or a sub-byte packing — that is [#501]. [#483]: https://github.com/visualcommons/gamut/issues/483 [#484]: https://github.com/visualcommons/gamut/issues/484 [#501]: https://github.com/visualcommons/gamut/issues/501 +[#612]: https://github.com/visualcommons/gamut/issues/612 From 7b1cc007ee74ca9404dfd8328af67c5ae0de58ab Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 10:22:48 -0400 Subject: [PATCH 90/94] fix(png): trim the trailing opaque alphas without a loop that can hang MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `trim_trailing_opaque` popped while `alphas.last() == Some(&OPAQUE)`. Invert that comparison and the loop never ends: an emptied vector answers `None`, and `None != Some(&OPAQUE)` holds forever, so it pops an empty vector for as long as anything is willing to wait. The diff mutation gate found it — the mutant did not survive, it timed out, which is a different fact and needs the opposite repair. An exclusion would have recorded the hang instead of removing it. Compute the length to keep instead: find the last entry that is not opaque, keep everything up to and including it, truncate. Same result, no loop, and every mutant of the new form changes the length a test already asserts. Refs #482 --- crates/gamut-png/src/palette.rs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/crates/gamut-png/src/palette.rs b/crates/gamut-png/src/palette.rs index 577b532c..a93a877a 100644 --- a/crates/gamut-png/src/palette.rs +++ b/crates/gamut-png/src/palette.rs @@ -181,10 +181,18 @@ pub(crate) const OPAQUE: u8 = 255; /// One owner for the rule, because both palette paths need it and a rule restated twice is a rule /// that can drift: the encoder-derived palette trims the alphas it collects /// ([`crate::reduce`]), and a caller-supplied one trims [`PngPalette::cleaned`]'s. +/// +/// Written as a search for the last entry that must stay rather than as a pop-until loop. The two +/// compute the same length, but the loop's condition has a form -- `last() == Some(&OPAQUE)` -- in +/// which inverting the comparison never terminates, because an emptied vector answers `None` and +/// `None != Some(&OPAQUE)` holds forever. That is a hang no test can distinguish from a slow one, +/// so the shape that cannot express it is the one to write. pub(crate) fn trim_trailing_opaque(alphas: &mut Vec) { - while alphas.last() == Some(&OPAQUE) { - alphas.pop(); - } + let keep = alphas + .iter() + .rposition(|&a| a != OPAQUE) + .map_or(0, |i| i + 1); + alphas.truncate(keep); } #[cfg(test)] From 87f34298fd11ae351a607fe974e6037a2a4f0748 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 13:13:40 -0400 Subject: [PATCH 91/94] fix(png): keep the palette entry a colour-form background names `encode_indexed8` marked an entry used for the pixels and for a one-byte `bKGD` payload only, with a comment asserting the index was "the one background form that survives this path". It is not: `bkgd_for` also resolves a two-byte grey sample and a six-byte RGB triple against the palette under an indexed colour type, and the builders promise exactly that conversion. So an RGB or grey background naming an otherwise-unused entry lost the entry, and the chunk vanished with it. Sharper: where the triple appears both opaque and transparent, the resolver prefers the opaque entry -- if no pixel names it, cleaning dropped it and the chunk was still written, now pointing at the transparent twin. An opaque background silently turned see-through, with nothing missing from the file to show for it. Which entry a payload names is now one function, `background_entry`, asked by both callers instead of restated beside one of them: `bkgd_for` converts the chunk for the header being written, and `encode_indexed8` marks the entry it names as used. The chunk is then written as that entry's index in the cleaned palette, so the resolution is taken once, against the palette the caller supplied, rather than a second time against the cleaned one -- which keeps the answer independent of the order cleaning leaves the survivors in. Cleaning is lossless again, so it stays silent. --- crates/gamut-png/src/ancillary.rs | 50 +++++++--- crates/gamut-png/src/encoder.rs | 146 ++++++++++++++++++++++++++---- crates/gamut-png/tests/oracle.rs | 72 +++++++++++++++ 3 files changed, 236 insertions(+), 32 deletions(-) diff --git a/crates/gamut-png/src/ancillary.rs b/crates/gamut-png/src/ancillary.rs index da7883ef..564857b4 100644 --- a/crates/gamut-png/src/ancillary.rs +++ b/crates/gamut-png/src/ancillary.rs @@ -716,6 +716,37 @@ impl WrittenHeader<'static> { } } +/// The palette entry a `bKGD` payload names in `palette`, or `None` if it names none. +/// +/// The payload names its own form by its length (§11.3.5.1): one byte is an index into the +/// palette the caller supplied, two a grey sample and six an RGB triple, each sample 16-bit +/// big-endian. A colour names the entry holding it, preferring an opaque entry over a transparent +/// twin of the same triple ([`WrittenPalette::index_of`]); an index names the entry it numbers, +/// and names nothing under a palette the encoder derived ([`PaletteOrigin::Derived`]) or past that +/// palette's end. +/// +/// One owner for the rule, because two callers need the same answer and a rule restated beside the +/// rule is a rule that drifts: [`bkgd_for`] converts the chunk for the header being written, and +/// [`crate::PngEncoder::encode_indexed8`] marks the entry this names as used, so cleaning the +/// caller's palette keeps it instead of dropping the background's colour out from under the chunk. +/// Restating "an index, and only an index, names an entry" beside this is what let a colour-form +/// background lose its entry. +pub(crate) fn background_entry(bkgd: &[u8], palette: WrittenPalette<'_>) -> Option { + let sample = |hi: u8, lo: u8| u16::from_be_bytes([hi, lo]); + let rgb: [u16; 3] = match *bkgd { + [index] => { + return (palette.origin == PaletteOrigin::Caller && usize::from(index) < palette.len()) + .then_some(index); + } + [hi, lo] => [sample(hi, lo); 3], + [r1, r0, g1, g0, b1, b0] => [sample(r1, r0), sample(g1, g0), sample(b1, b0)], + _ => return None, + }; + let entry = rgb.map(|v| u8::try_from(v).ok()); + let index = palette.index_of([entry[0]?, entry[1]?, entry[2]?])?; + u8::try_from(index).ok() +} + /// 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, @@ -738,28 +769,23 @@ impl WrittenHeader<'static> { /// 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". +/// +/// Which palette entry a payload names — in any of its three forms — is [`background_entry`]'s to +/// decide, not this function's; this one only says which colour types can carry the answer. 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] => { + [_] => { // 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]); + let index = background_entry(bkgd, written.palette?)?; + return (written.color == ColorType::Indexed).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.palette?.index_of(entry)?; - u8::try_from(index).ok().map(|index| vec![index]) - } + ColorType::Indexed => background_entry(bkgd, written.palette?).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()) diff --git a/crates/gamut-png/src/encoder.rs b/crates/gamut-png/src/encoder.rs index cc0cf94c..47425dde 100644 --- a/crates/gamut-png/src/encoder.rs +++ b/crates/gamut-png/src/encoder.rs @@ -27,6 +27,7 @@ use gamut_deflate::{DeflateEncoder, Level}; use crate::ancillary::{ Ancillary, PaletteOrigin, PhysicalUnit, SrgbIntent, WrittenHeader, WrittenPalette, + background_entry, }; use crate::backend::{IdatDeflater, IdatInfo, Registry, run_deflaters}; use crate::chunk::{self, C2paSpan, SIGNATURE}; @@ -437,6 +438,9 @@ impl PngEncoder { /// 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". + /// + /// Under [`encode_indexed8`](Self::encode_indexed8) the entry holding the grey is kept by the + /// palette cleaning even when no pixel names it, and the chunk becomes that entry's index. #[must_use] pub fn with_background_gray(mut self, gray: u16) -> Self { self.ancillary.bkgd = Some(gray.to_be_bytes().to_vec()); @@ -451,6 +455,10 @@ impl PngEncoder { /// 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". + /// + /// Under [`encode_indexed8`](Self::encode_indexed8) the entry holding the colour is kept by the + /// palette cleaning even when no pixel names it, and the chunk becomes that entry's index — so + /// the opaque entry a transparent twin would otherwise outlive is the one you keep. #[must_use] pub fn with_background_rgb(mut self, red: u16, green: u16, blue: u16) -> Self { let mut data = Vec::with_capacity(6); @@ -465,8 +473,8 @@ impl PngEncoder { /// /// 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). - /// It keeps naming that entry: cleaning the palette may renumber it, and the chunk is - /// renumbered with it, so the background written is the colour you pointed at. + /// It keeps naming that entry: cleaning the palette keeps the entry and may renumber it, and + /// the chunk is renumbered with it, so the background written is the colour you pointed at. /// 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)) @@ -849,11 +857,19 @@ impl PngEncoder { /// `palette` is **cleaned** before it is written, silently and losslessly: an entry nothing in /// the file names is dropped, a second entry holding the same RGB *and* alpha as an earlier one /// is merged into it, the trailing opaque `tRNS` bytes §11.3.2.1 lets a chunk omit are omitted, - /// the index bit depth is derived from what survives, and the image's indices — and a - /// [`with_background_index`](Self::with_background_index) background — are renumbered to match - /// ([`PngPalette::cleaned`]). The colour every pixel resolves to is unchanged, which is why - /// this reports nothing: a merged entry did not fail to come along, it arrived under another - /// index. Surviving entries keep the order you gave them. + /// the index bit depth is derived from what survives, and the image's indices are renumbered to + /// match ([`PngPalette::cleaned`]). The colour every pixel resolves to is unchanged, which is + /// why this reports nothing: a merged entry did not fail to come along, it arrived under + /// another index. Surviving entries keep the order you gave them. + /// + /// "Nothing in the file names it" includes the `bKGD` background, in **whichever** of its three + /// forms you set it — [`with_background_index`](Self::with_background_index), + /// [`with_background_gray`](Self::with_background_gray) or + /// [`with_background_rgb`](Self::with_background_rgb). Each names an entry of the palette you + /// supply, so that entry survives even when no pixel names it, and the chunk is written as its + /// index in the cleaned palette. Your background keeps its colour *and* its alpha: a triple + /// that appears both opaque and transparent resolves to the opaque entry, and it is that entry + /// the chunk keeps naming. /// /// What it costs is bounded by the palette, not by the picture: at most 256 entries, each /// compared against the survivors before it. Only the index remap walks the image, one pass @@ -876,33 +892,46 @@ impl PngEncoder { "PNG: palette index out of range", )); } - // Every entry the finished file still has to name. A pixel names one; so does an in-range - // `bKGD` palette index, which is the one background form that survives this path - // (`ancillary::bkgd_for`), so the entry behind it has to survive with it. + // Every entry the finished file still has to name. A pixel names one; so does the `bKGD` + // background, in whichever of its three forms it was set — an index into this palette, a + // grey sample, an RGB triple. Which entry each form names is `ancillary::background_entry` + // to answer, and it is asked rather than restated here, so no form can be missed. let mut used = [false; 256]; for &index in indices { used[usize::from(index)] = true; } let background = match self.ancillary.bkgd.as_deref() { - Some(&[index]) if usize::from(index) < palette.len() => Some(index), - _ => None, + Some(bkgd) => { + let supplied = palette.plte(); + background_entry( + bkgd, + WrittenPalette { + plte: &supplied, + trns: palette.trns(), + origin: PaletteOrigin::Caller, + }, + ) + } + None => None, }; if let Some(index) = background { used[usize::from(index)] = true; } let (palette, remap) = palette.cleaned(&used); let indices: Vec = indices.iter().map(|&i| remap[usize::from(i)]).collect(); - // The background still names the colour the caller chose, so its index moves with the - // entry. Only a background that actually moved copies the encoder's chunk state. + // The background still names the colour the caller chose, so the chunk is rewritten as + // that entry's index in the *cleaned* palette, whichever form it arrived in. Pinning the + // index here is also what stops a colour-form background from being resolved a second time + // against the cleaned palette and landing on a different entry — an opaque triple whose + // transparent twin outlived it resolves to the twin. Only a background whose bytes + // actually change copies the encoder's chunk state. let renumbered; - let this = match background.filter(|&index| remap[usize::from(index)] != index) { - Some(index) => { - renumbered = self - .clone() - .with_background_index(remap[usize::from(index)]); + let this = match background.map(|index| remap[usize::from(index)]) { + Some(index) if self.ancillary.bkgd.as_deref() != Some([index].as_slice()) => { + renumbered = self.clone().with_background_index(index); &renumbered } - None => self, + _ => self, }; let dims = image.dimensions(); @@ -1773,6 +1802,83 @@ mod tests { ); } + /// A background set as a *colour* names, after cleaning, an entry holding that colour with + /// that alpha. + /// + /// `bKGD` names a palette entry in three forms (§11.3.5.1) -- an index, a grey sample, an RGB + /// triple -- and `ancillary::background_entry` resolves all three against the caller's palette. + /// Marking only the index form's entry as used left the other two naming an entry cleaning was + /// free to drop or to renumber under them, which is the repainting the index form's mark + /// exists to prevent, one field over. It fails for one reason: the background stopped naming + /// the colour the caller set. + /// + /// Both rows are that one defect; they differ only in how it shows. In the first the entry is + /// named by nothing else, so it was dropped and the chunk vanished with it. In the second an + /// opaque entry and a transparent twin hold the same triple: the resolver prefers the opaque + /// one (`WrittenPalette::index_of`), no pixel names it, and dropping it left the chunk written + /// and + /// pointing at the transparent twin -- an opaque background silently turned see-through, with + /// nothing missing from the file to show for it. + /// + /// The colour is read back the way §11.3.5.1 says a reader reads it: the index into `PLTE`, + /// and the same index into `tRNS` (opaque past its end, §11.3.2.1). + #[test] + fn a_colour_background_names_an_entry_holding_its_colour() { + struct Case { + /// The palette the caller supplies, and its tRNS bytes. + entries: &'static [[u8; 3]], + alphas: &'static [u8], + /// The indices the image paints — never the background's entry. + painted: &'static [u8], + /// The background colour, and the alpha the entry it names must still have. + background: [u8; 3], + alpha: u8, + } + let cases = [ + Case { + entries: &[[10, 20, 30], [200, 100, 50]], + alphas: &[], + painted: &[0, 0, 0, 0], + background: [200, 100, 50], + alpha: 255, + }, + Case { + entries: &[[7, 7, 7], [7, 7, 7], [1, 2, 3]], + alphas: &[255, 0], + painted: &[1, 2, 1, 2], + background: [7, 7, 7], + alpha: 255, + }, + ]; + for case in cases { + let Case { + entries, + alphas, + painted, + background: [r, g, b], + alpha, + } = case; + let palette = PngPalette::with_transparency(entries, alphas).unwrap(); + let dims = Dimensions::new(painted.len() as u32, 1).unwrap(); + let img = ImageRef::::new(painted, dims).unwrap(); + let mut png = Vec::new(); + PngEncoder::new() + .with_background_rgb(r.into(), g.into(), b.into()) + .encode_indexed8(img, &palette, &mut png) + .unwrap(); + + let bkgd = find_chunk(&png, b"bKGD") + .unwrap_or_else(|| panic!("{r},{g},{b}: the background's entry was dropped")); + let index = usize::from(bkgd[0]); + let plte = find_chunk(&png, b"PLTE").expect("an indexed file has a palette"); + assert_eq!(&plte[index * 3..index * 3 + 3], &[r, g, b], "{r},{g},{b}"); + let written = find_chunk(&png, b"tRNS") + .and_then(|trns| trns.get(index).copied()) + .unwrap_or(255); + assert_eq!(written, alpha, "{r},{g},{b}: the background's alpha"); + } + } + /// A `bKGD` index past the end of the caller's palette is still omitted, not renumbered into /// range. /// diff --git a/crates/gamut-png/tests/oracle.rs b/crates/gamut-png/tests/oracle.rs index 1e632b17..0d438715 100644 --- a/crates/gamut-png/tests/oracle.rs +++ b/crates/gamut-png/tests/oracle.rs @@ -291,6 +291,78 @@ fn a_cleaned_palette_still_resolves_to_the_colours_the_caller_supplied() { ); } +/// A `bKGD` set as a *colour* keeps its palette entry, in the palette libpng resolves the file +/// through. +/// +/// An RGB triple names a palette entry as surely as an index does (§11.3.5.1, +/// `ancillary::background_entry`), so cleaning has to keep that entry even though no pixel names +/// it. It fails for one reason: the entry the background named was not kept — visible here as the +/// index depth, which follows the entry count and drops back to the four entries the pixels name. +/// +/// The pixel equality is not a second claim: it is what makes the depth evidence rather than a +/// number. The kept entry lengthens the palette and shifts every survivor after it, so libpng +/// resolving all 256 pixels to the caller's own RGBA is the statement that the palette which grew +/// is the palette the indices were remapped onto. libpng rather than our own decoder for the +/// reason [`a_cleaned_palette_still_resolves_to_the_colours_the_caller_supplied`] gives. +/// +/// The chunk's own bytes are asserted where they are written, in `encoder.rs`: this oracle reads +/// the file back through libpng, which does not surface `bKGD`. +#[test] +fn a_colour_background_keeps_its_entry_in_the_palette_libpng_resolves() { + // Entry 1 is the background's colour and no pixel names it; entry 5 repeats entry 0. + let rgb: [[u8; 3]; 6] = [ + [10, 10, 10], + [200, 30, 40], + [20, 20, 20], + [30, 30, 30], + [40, 40, 40], + [10, 10, 10], + ]; + let alpha: [u8; 6] = [255, 255, 0, 255, 255, 255]; + let palette = PngPalette::with_transparency(&rgb, &alpha).unwrap(); + + let painted = [0u8, 2, 3, 4, 5]; + let (w, h) = (16u32, 16u32); + let indices: Vec = (0..(w * h) as usize).map(|i| painted[i % 5]).collect(); + let encode = |encoder: PngEncoder| { + let mut png = Vec::new(); + encoder + .encode_indexed8( + ImageRef::::new(&indices, Dimensions::new(w, h).unwrap()).unwrap(), + &palette, + &mut png, + ) + .expect("encode"); + png + }; + let expected: Vec = indices + .iter() + .flat_map(|&index| { + let [r, g, b] = rgb[usize::from(index)]; + [r, g, b, alpha[usize::from(index)]] + }) + .collect(); + + let with_background = encode(PngEncoder::new().with_background_rgb(200, 30, 40)); + assert_eq!( + libpng_oracle::decode_rgba8(&with_background), + (w, h, expected.clone()) + ); + assert_eq!( + libpng_oracle::decode(&with_background).bit_depth, + 4, + "the kept entry is the fifth, so the indices no longer fit two bits" + ); + + let without = encode(PngEncoder::new()); + assert_eq!(libpng_oracle::decode_rgba8(&without), (w, h, expected)); + assert_eq!( + libpng_oracle::decode(&without).bit_depth, + 2, + "and without the background there are four entries, so they do" + ); +} + #[test] fn indexed8_rejects_out_of_range_index() { let palette = PngPalette::new(&[[0, 0, 0], [255, 255, 255]]).unwrap(); From 48cdb2baa391f41d1a0c56a377911787872f05c9 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 13:13:49 -0400 Subject: [PATCH 92/94] docs(png): say which background forms cleaning keeps, and name OPAQUE once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The claim corrected is the one the previous commit made false: the encoder's own doc, `STATUS.md` and the comment in `encode_indexed8` all said an *index* was the background form palette cleaning had to keep. All three now say what holds -- every form names an entry of the caller's palette, one rule with one owner, and the entry each names survives. `palette.rs` introduced `OPAQUE` for the tRNS trim while two methods two screens above still carried the literal 255 for the same §11.3.2.1 fact. They take the constant. --- crates/gamut-png/STATUS.md | 28 +++++++++++++++++----------- crates/gamut-png/src/palette.rs | 9 +++++++-- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/crates/gamut-png/STATUS.md b/crates/gamut-png/STATUS.md index 087ee432..8096445b 100644 --- a/crates/gamut-png/STATUS.md +++ b/crates/gamut-png/STATUS.md @@ -403,10 +403,12 @@ palette becomes the index of its entry (an opaque entry where a transparent twin 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. On that path the index is -renumbered with the entry it names when cleaning renumbers the palette, and the entry it names is -kept even when no pixel names it — the chunk is carried verbatim, so the alternative is a -background silently repainted. This holds across colour **types**; on the depth axis a `bKGD` sample +under an encoder-derived palette it names nothing and is omitted. On that path all three forms — the +index, the grey sample and the RGB triple — name an entry of the caller's palette, one rule with one +owner (`ancillary::background_entry`): the entry each names is kept even when no pixel names it, and +the chunk is written as that entry's index in the cleaned palette. The alternative is a background +silently repainted — including by the chunk resolving a second time against the cleaned palette and +landing on a transparent twin of the triple it asked for. 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]. ### Cleaning a caller's palette @@ -417,15 +419,19 @@ entries nothing names and entries that name a colour another entry already names into an incompressible `PLTE`, and the count of them decides the index bit depth. So the palette is cleaned before it is written (`PngPalette::cleaned`): an entry no pixel and no -`bKGD` index names is dropped, a later entry with the same RGB **and** the same alpha as an earlier -one is merged into it, the trailing opaque `tRNS` bytes §11.3.2.1 lets a chunk omit are omitted, the -index bit depth is derived from what survives, and the image's indices — and a -`with_background_index` background — are renumbered onto the result. Surviving entries keep the -caller's relative order; **ordering** a caller's palette is a separate, heuristic question ([#612]). +`bKGD` background names is dropped, a later entry with the same RGB **and** the same alpha as an +earlier one is merged into it, the trailing opaque `tRNS` bytes §11.3.2.1 lets a chunk omit are +omitted, the index bit depth is derived from what survives, and the image's indices — and the +background, in whichever of its three forms it was set — are renumbered onto the result. Surviving +entries keep the caller's relative order; **ordering** a caller's palette is a separate, heuristic +question ([#612]). It is silent and lossless, which is why it goes through no notice channel: a merged entry did not -fail to come along, it arrived under another index. libpng resolving the file to the caller's exact -RGBA is the test of that (`tests/oracle.rs`). +fail to come along, it arrived under another index, and a background's entry is kept rather than +dropped or re-resolved. libpng resolving the file to the caller's exact RGBA is the test of that +(`tests/oracle.rs`); the background's own chunk is asserted against §11.3.5.1's read rule — +`PLTE[index]`, and `tRNS[index]` for its alpha — beside the encoder, because libpng surfaces the +pixels but not `bKGD`. Measured on a 64×64 four-colour picture handed a full 256-entry palette (4 colours repeated 64 times, one of them transparent): diff --git a/crates/gamut-png/src/palette.rs b/crates/gamut-png/src/palette.rs index a93a877a..402a4184 100644 --- a/crates/gamut-png/src/palette.rs +++ b/crates/gamut-png/src/palette.rs @@ -89,13 +89,18 @@ impl PngPalette { if usize::from(index) >= self.rgb.len() { return None; } - Some(self.alpha.get(usize::from(index)).copied().unwrap_or(255)) + Some( + self.alpha + .get(usize::from(index)) + .copied() + .unwrap_or(OPAQUE), + ) } /// Whether any entry is not fully opaque (i.e. the palette carries transparency). #[must_use] pub fn has_transparency(&self) -> bool { - self.alpha.iter().any(|&alpha| alpha != 255) + self.alpha.iter().any(|&alpha| alpha != OPAQUE) } /// Always `false` — a palette has at least one entry (kept for API completeness). From ac3aba922f0eda45847a14b953b542a7a5932927 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 13:21:50 -0400 Subject: [PATCH 93/94] refactor(png): renumber the background unconditionally The diff mutation gate reported one survivor: replacing the match guard `self.ancillary.bkgd.as_deref() != Some([index].as_slice())` with `true`. It is not a gap in the tests. The guard only decided whether to skip a clone of the encoder's chunk state when the renumbered chunk would carry the bytes it already carries, so both sides of it write the same file and no assertion can tell them apart. Removing the branch removes the mutant, which is better than tolerating it: what is left is one clone on the one path that has a background at all, against a branch that bought nothing a reader could observe. --- crates/gamut-png/src/encoder.rs | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/crates/gamut-png/src/encoder.rs b/crates/gamut-png/src/encoder.rs index 47425dde..c33eeca6 100644 --- a/crates/gamut-png/src/encoder.rs +++ b/crates/gamut-png/src/encoder.rs @@ -921,17 +921,22 @@ impl PngEncoder { let indices: Vec = indices.iter().map(|&i| remap[usize::from(i)]).collect(); // The background still names the colour the caller chose, so the chunk is rewritten as // that entry's index in the *cleaned* palette, whichever form it arrived in. Pinning the - // index here is also what stops a colour-form background from being resolved a second time - // against the cleaned palette and landing on a different entry — an opaque triple whose - // transparent twin outlived it resolves to the twin. Only a background whose bytes - // actually change copies the encoder's chunk state. + // index is also what stops a colour-form background being resolved a second time against + // the cleaned palette and landing on a different entry — an opaque triple whose + // transparent twin outlived it would resolve to the twin. Unconditionally, so that the + // answer never depends on the order cleaning leaves the survivors in: guarding the copy on + // "the bytes would change" saves one clone of the encoder's chunk state on the one path + // that has a background at all, and buys it with a branch whose two sides write the same + // file. let renumbered; - let this = match background.map(|index| remap[usize::from(index)]) { - Some(index) if self.ancillary.bkgd.as_deref() != Some([index].as_slice()) => { - renumbered = self.clone().with_background_index(index); + let this = match background { + Some(index) => { + renumbered = self + .clone() + .with_background_index(remap[usize::from(index)]); &renumbered } - _ => self, + None => self, }; let dims = image.dimensions(); From bfb5b26a6fffec694b4fb5ae0158ca6fa8767edb Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 13:53:07 -0400 Subject: [PATCH 94/94] docs(png): rewrap the bKGD paragraph to the file's column --- crates/gamut-png/STATUS.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/gamut-png/STATUS.md b/crates/gamut-png/STATUS.md index 8096445b..3918b9db 100644 --- a/crates/gamut-png/STATUS.md +++ b/crates/gamut-png/STATUS.md @@ -408,8 +408,9 @@ index, the grey sample and the RGB triple — name an entry of the caller's pale owner (`ancillary::background_entry`): the entry each names is kept even when no pixel names it, and the chunk is written as that entry's index in the cleaned palette. The alternative is a background silently repainted — including by the chunk resolving a second time against the cleaned palette and -landing on a transparent twin of the triple it asked for. 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]. +landing on a transparent twin of the triple it asked for. 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]. ### Cleaning a caller's palette