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 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/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-cli/src/commands/convert.rs b/crates/gamut-cli/src/commands/convert.rs index 7e7970aa..9df5c53f 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,16 @@ 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. Anything that cannot be carried — the C2PA manifest store, + /// 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, } /// Output container/codec for `gamut convert`. @@ -242,6 +252,34 @@ 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 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(); + 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); + // 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)?; (rgba.len(), dims) } @@ -324,6 +362,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/src/commands/inspect.rs b/crates/gamut-cli/src/commands/inspect.rs index 7debfb0b..e66d3958 100644 --- a/crates/gamut-cli/src/commands/inspect.rs +++ b/crates/gamut-cli/src/commands/inspect.rs @@ -1,9 +1,57 @@ -//! `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. +//! +//! 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 +//! 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_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. +//! +//! `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. 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 — 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 +//! 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 +//! 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 +69,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 +83,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 +106,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 +135,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 @@ -326,15 +391,234 @@ 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` 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. +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); + 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> { + use gamut::png::{FilterScan, FilterType, SegmentKind}; + + // 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()); + 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() + ); + + // 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), + stats.count, + stats.payload_bytes, + stats.framing_bytes(), + if stats.is_ancillary() { + " (ancillary)" + } else { + "" + } + ); + } + if report.chunks.len() > MAX_LIST { + println!(" … and {} more", report.chunks.len() - MAX_LIST); + } + + match report.filters { + FilterScan::Counted(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() + ); + } + FilterScan::Skipped(reason) => { + println!( + " filters: not counted — {}", + filter_skip_label(reason) + ); + } + } + + 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 + ); + } + } + + // 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(|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 => format!( + "truncated from offset {} ({} bytes)", + seg.range.start, + seg.range.len() + ), + _ => format!( + "{} trailing bytes after IEND at offset {}", + seg.range.len(), + seg.range.start + ), + }) + .collect(); + // 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() + { + findings += 1; + if damaged.len() < MAX_LIST { + damaged.push(format!( + "filters not counted — {}", + filter_skip_label(reason) + )); + } + } + print_lines_of("findings", &damaged, findings); + + println!(" classified: {}", yes_no(report.is_fully_classified())); + println!(" intact: {}", yes_no(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 — {findings} finding(s)", + path.display(), + ))) + } +} + +/// 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::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", + // `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", } } @@ -343,6 +627,7 @@ fn format_name(format: Format) -> &'static str { match format { Format::Tiff => "TIFF", Format::Dng => "DNG", + Format::Png => "PNG", } } @@ -350,3 +635,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); + } +} 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-cli/tests/convert_metadata.rs b/crates/gamut-cli/tests/convert_metadata.rs new file mode 100644 index 00000000..1dd64a6f --- /dev/null +++ b/crates/gamut-cli/tests/convert_metadata.rs @@ -0,0 +1,112 @@ +//! 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, 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 { + 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) + .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 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", + 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"), + 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(), &[]); + + 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()); +} + +/// 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}" + ); +} diff --git a/crates/gamut-png/Cargo.toml b/crates/gamut-png/Cargo.toml index 2abaa9d2..bf09549b 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 @@ -26,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 @@ -33,3 +46,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/README.md b/crates/gamut-png/README.md index 20985453..084b698c 100644 --- a/crates/gamut-png/README.md +++ b/crates/gamut-png/README.md @@ -14,10 +14,18 @@ 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`; `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`; 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 @@ -50,7 +58,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 @@ -59,7 +68,12 @@ 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, 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 23faf5c6..3918b9db 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,9 @@ 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 | +| 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 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) @@ -49,3 +53,408 @@ 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 | + +## 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. + +**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`. 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 +`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, 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 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 +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 +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 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). + +## 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. 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. + +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 + 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. + +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 +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 | Clause | Outcome | +| --- | --- | --- | +| 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: + +- *`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). The `iTXt` language tag is checked for +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) + +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`. 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 + +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 | 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 | 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 | 119 | 119 | 138 | **−13.8%** | 3.719 | + +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. + +### 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 | | +| --- | --- | --- | --- | +| `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× | + +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** — 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`. 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. | +| 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`, 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 | emitted | IDAT | PLTE+tRNS emitted | libpng-9 | +| --- | --- | --- | --- | --- | +| 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 the candidates and keeps the smallest, the same way +`FilterStrategy::BruteForce` already resolves filters — no tuned constant, and never worse than +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 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 +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 (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. 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 + +`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` 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, 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): + +| 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 +[#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 +[#501]: https://github.com/visualcommons/gamut/issues/501 +[#612]: https://github.com/visualcommons/gamut/issues/612 diff --git a/crates/gamut-png/benches/encode.rs b/crates/gamut-png/benches/encode.rs new file mode 100644 index 00000000..821c5ed4 --- /dev/null +++ b/crates/gamut-png/benches/encode.rs @@ -0,0 +1,447 @@ +//! 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`). 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 +//! 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}; +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(); + print_heuristic_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 { + 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_transparent_cleanup(cleanup); + 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() + }, + ) + } +} + +/// 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} {:>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} {:>9} {:>8.1}% {:>7.3}", + case.name, + case.raw_len(), + default.len(), + best.len(), + cleaned.len(), + libpng.len(), + delta, + bpp(&best), + ); + } +} + +/// 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.histogram().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, + ); + } +} + +/// 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); + // 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" + } else { + "Bigrams" + }; + println!( + "{:<17} {:>10} {:>10} {:>10} {winner}", + case.name, msa, ent, big + ); + } +} + +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)); + } + + #[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() + }); + } +} diff --git a/crates/gamut-png/src/ancillary.rs b/crates/gamut-png/src/ancillary.rs index 44a3d4ae..564857b4 100644 --- a/crates/gamut-png/src/ancillary.rs +++ b/crates/gamut-png/src/ancillary.rs @@ -2,12 +2,46 @@ //! //! 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 ([`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 +//! 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 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_core::{Error, Result}; use gamut_deflate::{DeflateEncoder, Level}; -use crate::chunk; +use crate::decoded::XMP_KEYWORD; +use crate::encoder::MetadataNotice; +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). @@ -70,13 +104,184 @@ enum TextKind { Compressed, /// `iTXt`: uncompressed UTF-8. International, + /// `iTXt` with the compression flag set: zlib-compressed UTF-8. + 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 +/// 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'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 { - keyword: String, - text: String, + /// 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, + /// 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, + /// 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, + /// 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, +} + +/// 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.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. 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)." 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)"; + +/// 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) +} + +/// 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() +} + +/// 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. +/// +/// 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(latin1_byte) + .collect::>>() + else { + return Keyword::Drop(MetadataNotice::TextKeywordNotLatin1); + }; + if bytes.is_empty() || bytes.len() > 79 { + return Keyword::Drop(MetadataNotice::TextKeywordLength); + } + if !keyword.chars().all(printable_latin1) { + return Keyword::Write(bytes, Some(MetadataNotice::TextKeywordRepertoire)); + } + 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 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(latin1_byte).collect() +} + +/// 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'-') } /// Accumulated ancillary metadata to emit alongside the image. @@ -88,6 +293,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. @@ -100,8 +308,15 @@ 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, + /// 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 { @@ -130,17 +345,219 @@ 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, 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, + ) { + 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() && translated.contains('\0') { + entry.fault = Some(TextFault { + keyword: keyword.to_string(), + reason: TRANSLATED_NUL, + }); + } + 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(); + entry + } + + /// 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 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.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. + /// + /// 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) { - self.texts.push(TextEntry { - keyword: keyword.to_string(), - text: text.to_string(), + 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 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. + /// + /// 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, 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 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 { + 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()), + }; + TextEntry { + keyword: keyword_bytes, + text: text_bytes, + language: Vec::new(), + translated: Vec::new(), kind, - }); + carried: self.carrying, + xmp: false, + emit, + notices: notice.into_iter().collect(), + fault: refused.then(|| TextFault { + keyword: keyword.to_string(), + reason: TEXT_NUL, + }), + } + } + + /// Refuses an accumulation the spec forbids, before any byte is emitted. + /// + /// **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 + /// 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<()> { + 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(()) } /// 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((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() { @@ -161,22 +578,37 @@ 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]); } } - /// 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) { + /// 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( + &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]; @@ -188,39 +620,267 @@ 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 + // 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); + } } } -/// Serialises one text chunk (tEXt / zTXt / iTXt). +/// 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 palette for [`ColorType::Indexed`]; `None` otherwise. + pub palette: Option>, +} + +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, + palette: None, + } + } +} + +/// 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, +/// 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 — 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. +/// +/// 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 { + [_] => { + // An index names an entry only in the palette the caller supplied. + 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 => 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()) + } + 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 annotation. `entry.text` is already in the chunk's character set — Latin-1 +/// for `tEXt`/`zTXt` (§11.3.3.2, §11.3.3.3), UTF-8 for `iTXt` (§11.3.3.4) — because +/// [`Ancillary::text_entry`] converted it when the caller set it, so this function only frames +/// the bytes. fn write_text(out: &mut Vec, 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); } } @@ -230,6 +890,9 @@ 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::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. let mut i = 8; @@ -270,7 +933,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 +949,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 @@ -297,6 +960,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 { @@ -305,14 +1004,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 +1025,659 @@ 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::new(color, bit_depth) + } + + /// Three entries: red, a grey, blue. + const PLTE: [u8; 9] = [200, 30, 60, 77, 77, 77, 20, 90, 220]; + + 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, + ..palette(PaletteOrigin::Caller, None) + } + } + + #[test] + 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"); + // 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. + 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); + // 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); + // 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); + } + + /// 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() + } + + /// 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. + /// + /// 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é ÿ"); + assert_eq!( + find_chunk(&post_plte(&a), b"tEXt"), + Some(b"Author\0caf\xE9 \xFF".to_vec()) + ); + } + + /// §11.3.3.2: "Text containing characters outside the repertoire of ISO/IEC 8859-1 should be + /// 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 `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 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). + assert_eq!( + find_chunk(&out, b"iTXt"), + Some(b"Title\0\0\0\0\0\xE5\xAD\x97".to_vec()) + ); + } + + /// 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_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 every_latin1_character_stays_in_a_text_chunk() { + let mut a = Ancillary::default(); + 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\0one\x7Ftwo\n\xFF\xA0".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 `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 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"); + assert!( + itxt.len() < body.len(), + "the body is deflated, not copied: {} bytes", + itxt.len() + ); + } + + /// §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 a mutant that reinstates a refusal or drops one of the two chunks. + #[test] + fn a_profile_and_a_rendering_intent_are_both_written() { + let mut a = Ancillary::default(); + a.set_srgb(SrgbIntent::Perceptual); + a.iccp = Some(("prof".to_string(), vec![0u8; 4])); + 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!( + 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 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_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_dropped_with_a_notice() { + let mut ok = Ancillary::default(); + ok.add_text_latin1(&"k".repeat(79), "body"); + ok.add_text_latin1("k", "body"); + 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_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". + /// 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 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_written_with_a_notice() { + for keyword in [ + "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_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!( + 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. 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_verdict`], one condition at a time. + #[test] + 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_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!( + 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 + /// §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_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, 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 [`Ancillary::itxt_entry`]; the empty case pins that + /// "unspecified" is not itself a deviation. + #[test] + 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_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:?}" + ); + } + + 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!( + 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 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. 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 + /// — and its `emit` flag, without which the packet's *keyword* is written with no packet. + #[test] + fn a_non_utf8_xmp_packet_is_reported_not_written() { + let mut a = Ancillary::default(); + 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"", "", "", 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. + /// + /// 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. + /// + /// Kills [`Ancillary::add_text_international_tagged`] and the two `extend_from_slice` calls + /// for them in [`write_text`]: with either gone the payload is shorter and the tags empty. + #[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", false); + assert_eq!( + 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("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"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, + /// full-range flag — and fixes the matrix at 0 for PNG, so the setter has no argument for it. + /// + /// Kills the cICP arm of [`Ancillary::write_pre_plte`]: the two code points differ, so a + /// mutant that swaps them fails, and the literal `0` is asserted in its own position. + #[test] + fn cicp_is_written_with_the_matrix_fixed_at_zero() { + let full = Ancillary { + cicp: Some((9, 16, true)), + ..Default::default() + }; + let mut out = vec![0u8; 8]; + full.write_pre_plte(&mut out, DeflateEncoder::DEFAULT_EFFORT, RGB8); + assert_eq!(find_chunk(&out, b"cICP"), Some(vec![9, 16, 0, 1])); + + let narrow = Ancillary { + cicp: Some((1, 13, false)), + ..Default::default() + }; + let mut out = vec![0u8; 8]; + narrow.write_pre_plte(&mut out, DeflateEncoder::DEFAULT_EFFORT, RGB8); + assert_eq!(find_chunk(&out, b"cICP"), Some(vec![1, 13, 0, 0])); + } } diff --git a/crates/gamut-png/src/chunk.rs b/crates/gamut-png/src/chunk.rs index 5516e2a9..72378efc 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; @@ -11,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()); @@ -30,6 +43,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<'_> { @@ -39,6 +56,73 @@ 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, 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)] +#[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 **before the first +/// `IDAT`**, or `None`. +/// +/// 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)); + } + } + None +} + /// Iterates the chunks of a PNG stream after validating the signature (§5.2). pub(crate) struct ChunkReader<'a> { rest: &'a [u8], @@ -105,13 +189,133 @@ 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 + } +} + +/// 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, 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 +/// +/// ``` +/// 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")); + } + // 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 + // 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 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", + )); + } + 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)] @@ -188,6 +392,274 @@ 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"); + } + + /// 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 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()) + ); + + // 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 + /// 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] + 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. + #[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. + #[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/crc32.rs b/crates/gamut-png/src/crc32.rs index 2be972cd..09d2c2c2 100644 --- a/crates/gamut-png/src/crc32.rs +++ b/crates/gamut-png/src/crc32.rs @@ -3,53 +3,47 @@ //! 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(crate) 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). - pub(crate) fn new() -> Self { - Self { value: 0xFFFF_FFFF } + // 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. + // `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" + )] + pub fn new() -> Self { + Self(crc32fast::Hasher::new()) } /// Folds `data` into the running CRC. - pub(crate) 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; + pub fn update(&mut self, data: &[u8]) { + self.0.update(data); } /// Finalises the CRC (ones-complement of the register). - pub(crate) fn finish(self) -> u32 { - self.value ^ 0xFFFF_FFFF + pub fn finish(self) -> u32 { + self.0.finalize() } } diff --git a/crates/gamut-png/src/decoded.rs b/crates/gamut-png/src/decoded.rs index 2d1bbea7..5b93a061 100644 --- a/crates/gamut-png/src/decoded.rs +++ b/crates/gamut-png/src/decoded.rs @@ -2,26 +2,29 @@ //! §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; 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. @@ -86,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 { @@ -100,11 +103,60 @@ 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, +} + +/// 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); -/// 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 { @@ -116,6 +168,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. @@ -134,21 +188,43 @@ 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`. 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 + /// only when it fits the metadata budget; see [`c2pa_ignored`](Self::c2pa_ignored). + pub c2pa: Option>, + /// 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 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, /// gAMA: image gamma × 100 000 (§11.3.2.2) — the unit the encoder's `with_gamma` writes. 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, @@ -165,7 +241,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. /// @@ -190,36 +267,85 @@ 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`. 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 + /// [`c2pa_ignored`](Self::c2pa_ignored). + pub c2pa: Option>, + /// 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 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, /// gAMA: image gamma × 100 000 (§11.3.2.2). 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, } /// 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; 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 +/// [`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; + // 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 { + // 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; + } + } + } b"iCCP" if meta.icc_profile.is_none() => { meta.icc_profile = parse_iccp(data, &mut budget); } @@ -257,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), @@ -271,12 +398,14 @@ 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. +/// 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), } @@ -334,7 +463,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 { @@ -342,10 +471,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()?; @@ -358,10 +488,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)?; @@ -378,13 +509,28 @@ 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, 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 + }, })) } @@ -474,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]; @@ -520,6 +698,62 @@ 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_ignored, 2); + + let single = collect(&[(CABX, b"only")], 1024); + assert_eq!(single.c2pa.as_deref(), Some(&b"only"[..])); + assert_eq!(single.c2pa_ignored, 0); + assert_eq!(collect(&[], 1024).c2pa, None); + } + + /// 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_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_ignored, 300); + } + + /// `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_ignored, 2, + "both are ignored: the store busted the budget, and the next is 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 cb95bbf0..1ffc18f2 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}; @@ -35,8 +35,12 @@ 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; -/// Default cumulative cap on inflated metadata (iCCP/zTXt/iTXt) payloads: 16 MiB. +/// +/// `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 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; @@ -135,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; @@ -174,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 @@ -220,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; @@ -317,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)); + } } } _ => { @@ -348,6 +367,7 @@ impl PngDecoder { trns, idat, ancillary, + c2pa_after_idat, }) } @@ -360,17 +380,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"), @@ -385,7 +405,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 /// @@ -394,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, @@ -412,6 +436,9 @@ 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, gamma: meta.gamma, chromaticities: meta.chromaticities, @@ -451,8 +478,8 @@ 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. @@ -522,6 +549,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. @@ -529,7 +561,12 @@ 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 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> { let mut reader = ChunkReader::new(data)?; let first = reader .next_chunk()? @@ -551,6 +588,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 { @@ -577,11 +616,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)); + } } } _ => { @@ -601,16 +645,28 @@ 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. /// /// 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. @@ -658,8 +714,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), @@ -1323,8 +1383,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, @@ -1588,6 +1649,7 @@ mod tests { PngEncoder::new() .with_gamma(1.0 / 2.2) .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) .with_icc_profile("prof", b"not-a-real-profile-but-bytes") @@ -1630,7 +1692,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/deconstruct.rs b/crates/gamut-png/src/deconstruct.rs new file mode 100644 index 00000000..04c07a9d --- /dev/null +++ b/crates/gamut-png/src/deconstruct.rs @@ -0,0 +1,1239 @@ +//! 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 std::collections::HashMap; + +use gamut_core::{Error, Result}; + +use crate::chunk::{C2paSpan, CABX, ChunkReader, RawChunk, SIGNATURE}; +use crate::decoded::PngHeader; +use crate::decoder::DEFAULT_MAX_IMAGE_BYTES; +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() + } +} + +/// 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 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 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] + 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] + 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, 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. + 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, + /// 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. 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 | Self::ImplausibleInflation) + } +} + +/// 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) 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 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 { + /// **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 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 + /// 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. 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_damage() + && 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() + } + + /// 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`. + #[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. + /// + /// `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 { + 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 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, + ) + } + + /// 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`. + /// + /// 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 { + 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. + /// + /// 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 + .iter() + .find(|stats| &stats.chunk_type == chunk_type) + .copied() + } +} + +/// 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>, + /// Lookup work done so far, in entries examined — the probe that makes this type's + /// complexity assertable by count rather than by clock. Charged by + /// [`lookup`](Self::lookup), which is where the examining happens. + #[cfg(test)] + probes: usize, +} + +impl ChunkTally { + /// An empty tally. + fn new() -> Self { + Self { + stats: Vec::new(), + index: HashMap::new(), + #[cfg(test)] + probes: 0, + } + } + + /// Adds one chunk of `chunk_type` carrying `payload_len` payload bytes. + /// + /// 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) { + match self.lookup(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, + }); + } + } + } + + /// 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 + } +} + +/// 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). +/// +/// Walks under [`DeconstructLimits::default()`]. Use +/// [`deconstruct_with_limits`] to match a decoder you configured yourself. +/// +/// # Errors +/// +/// 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. + /// + /// 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 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. + /// + /// 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. + /// + /// 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, +} + +/// 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(), + 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 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 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 { + 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 { + 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 tally, &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(_) => { + // `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(); + debug_assert!( + start < png.len(), + "a framing error leaves bytes unaccounted" + ); + 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 = scan_filters( + &native, + &idat, + filtered_len, + &passes, + limits.max_image_bytes, + ); + + Ok(PngReport { + file_len: png.len(), + header, + segments, + chunks: tally.into_stats(), + 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(); + 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 { + 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(); + }; + // `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, + height, + row_bytes, + filtered_len, + }); + } + out +} + +/// 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. +/// +/// 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) +} + +/// 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 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 +/// 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 +/// 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 +/// 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], + max_image_bytes: usize, +) -> FilterScan { + 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::ImplausibleInflation); + } + let Ok(stream) = inflate::inflate_zlib(idat, filtered_len) else { + return FilterScan::Skipped(SkippedFilterScan::CorruptStream); + }; + if stream.len() != filtered_len { + return FilterScan::Skipped(SkippedFilterScan::LengthMismatch); + } + let mut counts = [0u32; 5]; + let mut at = 0usize; + for pass in passes { + for _ in 0..pass.height { + // 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; + } + } + FilterScan::Counted(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: FilterScan::Skipped(SkippedFilterScan::CorruptStream), + } + } + + #[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()); + } + + /// 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_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] + 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 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, + SkippedFilterScan::UndefinedFilterCode, + ] { + assert!(reason.is_damage(), "{reason:?}"); + assert!(FilterScan::Skipped(reason).is_damage(), "{reason:?}"); + } + 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); + assert_eq!(SkippedFilterScan::ImplausibleInflation as u8, 4); + } + + #[test] + 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 + // `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::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, + 16384 * (16384 * 4 + 1), + "the header-derived figure is still reported" + ); + } + + /// 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(); + 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)); + } + } + + /// The complexity claim itself, by count rather than by clock: N chunks cost N lookup + /// 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; + 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. + 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 + ); + } +} diff --git a/crates/gamut-png/src/encoder.rs b/crates/gamut-png/src/encoder.rs index da9575c4..c33eeca6 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, @@ -8,13 +25,19 @@ use gamut_core::{ }; use gamut_deflate::{DeflateEncoder, Level}; -use crate::ancillary::{Ancillary, PhysicalUnit, SrgbIntent}; +use crate::ancillary::{ + Ancillary, PaletteOrigin, PhysicalUnit, SrgbIntent, WrittenHeader, WrittenPalette, + background_entry, +}; 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::decoded::{ + Chromaticities, Cicp, DecodedPng, IccProfile, PngMetadata, TextChunk, TextChunkKind, XmpFraming, +}; 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 @@ -22,15 +45,179 @@ 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, ]; +/// 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, +} + +/// 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]>, + /// 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, + 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, +} + +/// Something [`PngEncoder::with_metadata`] could not do faithfully with a payload it was given. +/// +/// 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 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`), 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 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 { + 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" + } + 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 MetadataNotice { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str(self.reason()) + } +} + /// A reusable PNG encoder. #[derive(Debug, Clone)] pub struct PngEncoder { @@ -39,7 +226,13 @@ pub struct PngEncoder { filter: FilterStrategy, ancillary: Ancillary, auto_reduce: bool, + clean_transparent: bool, backends: Registry, + /// 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 { @@ -59,7 +252,9 @@ impl PngEncoder { filter: FilterStrategy::MinSumAbs, ancillary: Ancillary::default(), auto_reduce: false, + clean_transparent: false, backends: Registry::default(), + carry_notices: Vec::new(), } } @@ -122,6 +317,26 @@ 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. 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; + 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 @@ -142,13 +357,42 @@ 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). + /// + /// 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); 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( @@ -174,6 +418,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()); @@ -181,6 +432,15 @@ 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". + /// + /// 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()); @@ -188,6 +448,17 @@ 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". + /// + /// 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); @@ -199,6 +470,16 @@ 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). + /// 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)) + /// 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]); @@ -257,9 +538,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`. (Mutually exclusive with [`Self::with_srgb`] - /// per the spec; set only one.) + /// 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`. + /// + /// 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())); @@ -270,14 +553,328 @@ 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); + // §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 } + /// 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`]. + /// + /// 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, 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. 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 + /// [`XmpFraming`](crate::XmpFraming). + /// + /// 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. + /// + /// 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 + /// builder methods). + #[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(), + xmp_framing: metadata.xmp_framing.as_ref(), + texts: &metadata.texts, + gamma: metadata.gamma, + chromaticities: metadata.chromaticities, + srgb: metadata.srgb, + cicp: metadata.cicp, + c2pa: metadata.c2pa.is_some(), + }) + } + + /// 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(), + xmp_framing: decoded.xmp_framing.as_ref(), + texts: &decoded.texts, + gamma: decoded.gamma, + chromaticities: decoded.chromaticities, + srgb: decoded.srgb, + cicp: decoded.cicp, + c2pa: decoded.c2pa.is_some(), + }) + } + + /// 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 — 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 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.carry_notices.clear(); + self.ancillary.begin_carry(); + if let Some(exif) = meta.exif { + self = self.with_exif(exif); + } + // 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); + } + 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.carry_notices.push(MetadataNotice::NonRgbCicp); + } + Some(cicp) => { + self = self.with_cicp( + cicp.color_primaries, + cicp.transfer_function, + cicp.full_range, + ); + } + None => {} + } + if meta.c2pa { + 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 + // 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, + ]); + } + // 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 { + 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) = ( + 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, + translated, + &text.text, + false, + ), + TextChunkKind::CompressedInternational => { + self.ancillary.add_text_international_tagged( + &text.keyword, + language, + translated, + &text.text, + true, + ); + } + } + } + self.ancillary.end_carry(); + 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. + /// + /// 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 + /// 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 (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. 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 { + 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, 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 + /// + /// 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. /// + /// `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 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 + /// 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`. @@ -295,24 +892,78 @@ impl PngEncoder { "PNG: palette index out of range", )); } + // 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(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 the chunk is rewritten as + // that entry's index in the *cleaned* palette, whichever form it arrived in. Pinning the + // 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 { + 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, - ColorType::Indexed, - depth, + WrittenHeader { + color: ColorType::Indexed, + bit_depth: depth, + palette: Some(WrittenPalette { + plte: &plte, + trns, + origin: PaletteOrigin::Caller, + }), + }, |out| { chunk::write_chunk(out, *b"PLTE", &plte); if let Some(alpha) = trns { @@ -334,41 +985,169 @@ impl PngEncoder { self.write_png( (dims.width, dims.height), image.as_samples(), - color, - 8, + WrittenHeader::new(color, 8), |_| {}, out, ) } + /// 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 { + return self.write_reduced_or_native( + dims, + reduce::analyze8(samples, channels), + |o| { + self.write_png( + (dims.width, dims.height), + samples, + WrittenHeader::new(color, 8), + |_| {}, + o, + ) + }, + out, + ); + } + self.write_png( + (dims.width, dims.height), + samples, + WrittenHeader::new(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 { + return self.write_reduced_or_native( + dims, + reduce::analyze16(samples, channels), + |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 *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, + 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 prefers_plain(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> { + self.clean_transparent + .then(|| reduce::clean_transparent(samples, channels)) + .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()); } - 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 + + /// 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 `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 { + // 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; let bpp = bits_per_pixel.div_ceil(8).max(1); @@ -377,9 +1156,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, and the C2PA store last: immediately before IDAT. + self.ancillary.write_post_plte(out, self.effort, written); let idat = self.compress_scanlines( sample_bytes, @@ -440,6 +1221,79 @@ impl PngEncoder { } } + /// 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. + /// + /// 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 candidates are encoded and the smallest kept. + /// That is exactly what [`FilterStrategy::BruteForce`] already does for filters, and it needs + /// no tuned constant. + /// + /// **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.** 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 + /// 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, + reductions: Reductions, + native: impl FnOnce(&mut Vec) -> Result, + out: &mut Vec, + ) -> Result { + 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 native_encoding = Vec::new(); + native(&mut native_encoding)?; + + let winner = if prefers_native(native_encoding.len(), reduced_encoding.len()) { + native_encoding + } else { + reduced_encoding + }; + out.extend_from_slice(&winner); + Ok(winner.len()) + } + /// Writes a reduced encoding chosen by [`reduce::analyze8`] / [`reduce::analyze16`]. fn write_reduced( &self, @@ -462,26 +1316,76 @@ 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) - } - 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) + 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, + WrittenHeader::new(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, + WrittenHeader::new(ColorType::Grayscale, 8), + |out| chunk::write_chunk(out, *b"tRNS", &[0, key]), + 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, @@ -503,8 +1407,15 @@ impl PngEncoder { self.write_png( wh, sample_bytes, - ColorType::Indexed, - depth, + WrittenHeader { + color: ColorType::Indexed, + bit_depth: depth, + palette: Some(WrittenPalette { + plte: &plte, + trns: trns.as_deref(), + origin: PaletteOrigin::Derived, + }), + }, |out| { chunk::write_chunk(out, *b"PLTE", &plte); if let Some(alpha) = &trns { @@ -518,6 +1429,74 @@ impl PngEncoder { } } +/// Whether the uncleaned encoding beats the cleaned one, for [`PngEncoder::cleaned_or_plain`]. +/// +/// **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. +fn prefers_plain(plain_len: usize, cleaned_len: usize) -> bool { + plain_len <= cleaned_len +} + +/// 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.** 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 +} + +/// Whether the unreduced encoding beats the winning reduction, for +/// [`PngEncoder::write_reduced_or_native`]. +/// +/// **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 { + 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() { @@ -533,10 +1512,13 @@ 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) - { - return self.write_reduced(image.dimensions(), reduced, out); + if self.auto_reduce { + return self.write_reduced_or_native( + image.dimensions(), + reduce::analyze8(image.as_samples(), 1), + |o| self.encode_8bit(image, ColorType::Grayscale, o), + out, + ); } self.encode_8bit(image, ColorType::Grayscale, out) } @@ -554,8 +1536,7 @@ impl EncodeImage for PngEncoder { self.write_png( (dims.width, dims.height), &packed, - ColorType::Grayscale, - 1, + WrittenHeader::new(ColorType::Grayscale, 1), |_| {}, out, ) @@ -563,72 +1544,99 @@ 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) - { - return self.write_reduced(image.dimensions(), reduced, out); + if self.auto_reduce { + return self.write_reduced_or_native( + image.dimensions(), + reduce::analyze8(image.as_samples(), 3), + |o| self.encode_8bit(image, ColorType::Truecolor, o), + out, + ); } self.encode_8bit(image, ColorType::Truecolor, out) } } impl EncodeImage for PngEncoder { fn encode_image(&self, image: ImageRef<'_, Rgba8>, out: &mut Vec) -> Result { - if self.auto_reduce - && let Some(reduced) = reduce::analyze8(image.as_samples(), 4) - { - return self.write_reduced(image.dimensions(), reduced, out); + let dims = image.dimensions(); + 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.encode_8bit(image, ColorType::TruecolorAlpha, out) } } impl EncodeImage for PngEncoder { fn encode_image(&self, image: ImageRef<'_, GrayAlpha8>, out: &mut Vec) -> Result { - if self.auto_reduce - && let Some(reduced) = reduce::analyze8(image.as_samples(), 2) - { - return self.write_reduced(image.dimensions(), reduced, out); + let dims = image.dimensions(); + 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.encode_8bit(image, ColorType::GrayscaleAlpha, out) } } impl EncodeImage for PngEncoder { fn encode_image(&self, image: ImageRef<'_, Gray16>, out: &mut Vec) -> Result { - if self.auto_reduce - && let Some(reduced) = reduce::analyze16(image.as_samples(), 1) - { - return self.write_reduced(image.dimensions(), reduced, out); + let (dims, samples) = (image.dimensions(), image.as_samples()); + if self.auto_reduce { + return self.write_reduced_or_native( + dims, + reduce::analyze16(samples, 1), + |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 { - if self.auto_reduce - && let Some(reduced) = reduce::analyze16(image.as_samples(), 3) - { - return self.write_reduced(image.dimensions(), reduced, out); + let (dims, samples) = (image.dimensions(), image.as_samples()); + if self.auto_reduce { + return self.write_reduced_or_native( + dims, + reduce::analyze16(samples, 3), + |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 { - if self.auto_reduce - && let Some(reduced) = reduce::analyze16(image.as_samples(), 4) - { - return self.write_reduced(image.dimensions(), reduced, out); + let dims = image.dimensions(); + 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(image, ColorType::TruecolorAlpha, out) } } impl EncodeImage for PngEncoder { fn encode_image(&self, image: ImageRef<'_, GrayAlpha16>, out: &mut Vec) -> Result { - if self.auto_reduce - && let Some(reduced) = reduce::analyze16(image.as_samples(), 2) - { - return self.write_reduced(image.dimensions(), reduced, out); + let dims = image.dimensions(); + 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(image, ColorType::GrayscaleAlpha, out) } } @@ -660,36 +1668,249 @@ 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])); } + /// 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 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. + /// + /// 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, @@ -698,14 +1919,23 @@ mod tests { /// `1u8 << depth`, which overflows at 8. #[test] fn indexed_at_depth_eight_is_not_bit_packed() { - // 32 distinct opaque colours over 200 pixels: more than 16, so the index depth is 8, and - // cheap enough that the palette still beats raw RGB. + // 32 distinct opaque colours over 1024 pixels: more than 16, so the index depth is 8. + // + // Pseudo-random rather than cycling, and 1024 pixels rather than 200, because + // `write_reduced_or_native` races the palette against the unreduced encoding and keeps + // whichever is smaller. A period-32 cycle over 200 pixels compresses to an 82-byte RGB + // file, which a 96-byte `PLTE` cannot beat before a single index is written -- the race + // correctly declines the palette, and pinning `Indexed` there would assert the defect the + // race exists to fix. Shuffling denies DEFLATE the period and 1024 pixels amortise the + // palette: 479 bytes indexed against 525 unreduced. let mut rgb = Vec::new(); - for i in 0..200u32 { - let c = (i % 32) as u8; + for i in 0..1024u32 { + let mut h = i.wrapping_mul(2654435761); + h ^= h >> 15; + let c = (h % 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 img = ImageRef::::new(&rgb, Dimensions::new(1024, 1).unwrap()).unwrap(); let mut png = Vec::new(); // Reduction is opt-in; without it the encoder writes the input layout unchanged and the // indexed path -- the one this test is about -- is never reached. @@ -763,6 +1993,36 @@ 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 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_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 plain encoding, which altered no stored sample" + ); + } + #[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 @@ -803,4 +2063,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/src/filter.rs b/crates/gamut-png/src/filter.rs index 8c5c100b..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, @@ -31,6 +36,24 @@ 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. + /// + /// 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. + /// + /// 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, @@ -70,21 +93,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)); + } + } } } @@ -117,10 +196,110 @@ 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. +/// +/// 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 { + fn new() -> Self { + Self { + histogram: [0; 256], + bigrams: vec![0; 1 << 10], + dirty: Vec::new(), + } + } +} + +/// 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 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 > 0) + .map(|&c| f64::from(c) * (n / f64::from(c)).log2()) + .sum(); + (bits * 256.0) as u64 + } + Score::Bigrams => { + 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` + // 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 { + // 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 + } + } +} + /// 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, @@ -131,29 +310,65 @@ pub(crate) 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); + 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]; - 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. - FilterStrategy::MinSumAbs | FilterStrategy::BruteForce => { - choose_min_sum_abs(cur, prev, bpp, &mut scratch) + 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); } - }; - out.push(filter as u8); - filter_row(filter, cur, prev, bpp, &mut scratch); - out.extend_from_slice(&scratch); + None => { + 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. -fn choose_min_sum_abs(cur: &[u8], prev: &[u8], bpp: usize, scratch: &mut Vec) -> FilterType { +/// 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], + 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; + // `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, @@ -162,10 +377,12 @@ fn choose_min_sum_abs(cur: &[u8], prev: &[u8], bpp: usize, scratch: &mut Vec FilterType::Paeth, ] { filter_row(filter, cur, prev, bpp, scratch); - let score = sum_abs(scratch); - 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); } } best @@ -257,13 +474,139 @@ 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); + // 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] + 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 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_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 + // 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 // 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_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/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/src/lib.rs b/crates/gamut-png/src/lib.rs index 2b65a659..ba4cab1c 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,20 @@ //! 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). [`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 //! //! The PNG codestream is the concatenated-IDAT **zlib stream**, and it is where PNG spends its @@ -55,6 +70,7 @@ mod color; mod crc32; mod decoded; mod decoder; +mod deconstruct; mod encoder; mod filter; mod ihdr; @@ -62,16 +78,27 @@ 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}; pub use backend::{IdatDeflater, IdatInflater, IdatInfo}; +pub use chunk::{C2paSpan, fill_c2pa}; pub use color::ColorType; pub use decoded::{ Chromaticities, Cicp, DecodedPng, IccProfile, PngHeader, PngImage, PngMetadata, TextChunk, + TextChunkKind, XmpFraming, }; pub use decoder::{PngDecoder, TransparencyKey, metadata}; -pub use encoder::PngEncoder; +pub use deconstruct::{ + ChunkStats, DEFAULT_MAX_CHUNKS, DeconstructLimits, FilterHistogram, FilterScan, PassStats, + PngReport, Segment, SegmentKind, SkippedFilterScan, deconstruct, deconstruct_with_limits, +}; +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/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/palette.rs b/crates/gamut-png/src/palette.rs index 4874b5cc..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). @@ -104,6 +109,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() @@ -119,6 +176,30 @@ 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 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) { + let keep = alphas + .iter() + .rposition(|&a| a != OPAQUE) + .map_or(0, |i| i + 1); + alphas.truncate(keep); +} + #[cfg(test)] mod tests { @@ -177,6 +258,114 @@ 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 + /// `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]); + } + + /// [`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/src/reduce.rs b/crates/gamut-png/src/reduce.rs index fa154dde..ab62eb1e 100644 --- a/crates/gamut-png/src/reduce.rs +++ b/crates/gamut-png/src/reduce.rs @@ -3,18 +3,26 @@ //! 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; use crate::pack::gray8_scale; +use crate::palette; /// 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 { @@ -35,6 +43,24 @@ pub(crate) 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). @@ -48,6 +74,33 @@ pub(crate) 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 { @@ -58,6 +111,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] { @@ -73,10 +169,93 @@ 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 +/// 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. +/// +/// 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; + for px in pixels.chunks_exact(channels) { + let key = pixel_key(px, channels); + match key[3] { + 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, + } + } + 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. -pub(crate) 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; @@ -139,40 +318,96 @@ pub(crate) fn analyze8(pixels: &[u8], channels: usize) -> Option { } else { 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) .min(gray_alpha_size) - .min(rgb_size); + .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 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]); - } - Some(Reduced::Rgb8(out)) - } else { - Some(build_indexed(pixels, channels, &palette, &palette_index)) + // 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| px[0] / scale) + .collect(), + } + } 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(&px[0..3]); + } + Reduced::Rgb8(out) + } + }); + + // 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) + } + }; + + 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, + }, } } @@ -181,11 +416,16 @@ 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 { +/// +/// 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, @@ -193,7 +433,18 @@ pub(crate) 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; @@ -210,22 +461,23 @@ pub(crate) 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 } } @@ -246,6 +498,35 @@ 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]); + // 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 +} + /// Builds the indexed reduction from the collected palette. fn build_indexed( pixels: &[u8], @@ -253,28 +534,41 @@ 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] != 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. // - // No length guard: this arm runs only when `needs_trns` held, i.e. some entry's alpha is - // not 255, so the `last() == 255` test always halts the loop before the vector empties. - // 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(); - } + // No length guard: this arm runs only when some entry's alpha is not 255, so the + // `last() == 255` test always halts the loop before the vector empties. Ordering makes + // that argument stronger rather than weaker -- the entry that halts it is at index 0, so + // 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. + palette::trim_trailing_opaque(&mut alphas); Some(alphas) } else { None }; Reduced::Indexed { - depth: index_bit_depth(palette.len()), + depth: index_bit_depth(ordered.len()), indices, plte, trns, @@ -285,13 +579,77 @@ 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 + // "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. 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"), } } @@ -300,10 +658,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"), } } @@ -325,10 +683,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"), } } @@ -353,9 +711,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"); @@ -364,6 +726,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 @@ -386,7 +835,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()), @@ -414,7 +866,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]), @@ -437,12 +892,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()); @@ -458,7 +917,78 @@ 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 + /// 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) { + Reductions::Chunked { + chunked: 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] @@ -469,7 +999,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"), } } @@ -477,14 +1010,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}"), } } @@ -512,9 +1045,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()); @@ -528,7 +1065,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] @@ -541,12 +1078,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]); @@ -555,12 +1096,71 @@ 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) { + 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. + 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 a chunk-carrying GrayKeyed, got {}", + match other { + 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", + } + ), + } + } + #[test] fn grey_alpha_noise_keeps_its_encoding() { 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] @@ -573,10 +1173,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"), } } @@ -594,7 +1194,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); @@ -607,7 +1207,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| { @@ -615,14 +1215,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] @@ -633,8 +1233,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. @@ -648,8 +1250,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"), } } @@ -666,10 +1270,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"), } } @@ -680,11 +1284,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. @@ -696,7 +1300,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. @@ -705,7 +1309,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 @@ -713,7 +1317,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| { [ @@ -724,7 +1328,7 @@ mod tests { ] }) .collect(); - assert!(analyze16(&noise, 4).is_none()); + assert!(matches!(analyze16(&noise, 4), Reductions::None)); } #[test] @@ -737,8 +1341,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/src/stages.rs b/crates/gamut-png/src/stages.rs new file mode 100644 index 00000000..b8a4c87f --- /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::filter_image; +pub use crate::pack::pack_scanlines; +pub use crate::reduce::{Reduced, analyze8, analyze16}; diff --git a/crates/gamut-png/tests/accounting.rs b/crates/gamut-png/tests/accounting.rs new file mode 100644 index 00000000..cd5a7985 --- /dev/null +++ b/crates/gamut-png/tests/accounting.rs @@ -0,0 +1,833 @@ +//! 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, Rgba8}; +use gamut_png::{ + 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 +/// 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); +} + +/// 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. +/// 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, + ] +} + +/// 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. +/// 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 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* — 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() { + /// 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); + 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 are the same bytes apart from the types they use" + ); + + let repeated_report = deconstruct(&repeated).expect("deconstruct"); + let distinct_report = deconstruct(&distinct).expect("deconstruct"); + + 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); +} + +#[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); + 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 + .histogram() + .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})" + ); + } +} + +/// 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.histogram().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, + // 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.histogram().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, + 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); + assert!(report.idat_compressed > 0); + 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 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. + // 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", &[])); + } + chunks.push(common::chunk(b"IEND", &[])); + let png = common::png_from_chunks(&chunks); + + 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(), + 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(CHUNKS - 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}" + ); + + // 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 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)) + .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] +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 + // 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, + FilterScan::Skipped(SkippedFilterScan::OverBudget), + "declined: over the decoder's byte budget" + ); + assert!( + 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. +/// +/// §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" + ); +} + +/// 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"); + 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.histogram().expect("sound stream").total(), + 32 + ); +} 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..a67a3564 --- /dev/null +++ b/crates/gamut-png/tests/ancillary_colour_type.rs @@ -0,0 +1,353 @@ +//! `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 +} + +/// 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 + // 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])); +} diff --git a/crates/gamut-png/tests/backends.rs b/crates/gamut-png/tests/backends.rs index 6c649123..ed79077a 100644 --- a/crates/gamut-png/tests/backends.rs +++ b/crates/gamut-png/tests/backends.rs @@ -22,8 +22,17 @@ 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: +/// +/// * `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 +68,7 @@ const GOLDEN: [(&str, &str); 11] = [ ), ( "rgb8_best_bruteforce", - "89504e470d0a1a0a0000000d49484452000000080000000808020000004b6d29dc000000244944415478da636160e713c5065856ac58418404828357079a14761d081e5ea3b0ca0100921322178646d81f0000000049454e44ae426082", + "89504e470d0a1a0a0000000d49484452000000080000000808020000004b6d29dc000000154944415478da636460e713c5069856e0008353020008cb701e6f73d8bc0000000049454e44ae426082", ), ( "rgb8_fast", diff --git a/crates/gamut-png/tests/c2pa.rs b/crates/gamut-png/tests/c2pa.rs new file mode 100644 index 00000000..fc58e84f --- /dev/null +++ b/crates/gamut-png/tests/c2pa.rs @@ -0,0 +1,642 @@ +//! 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. 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; + +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, + fill_c2pa, +}; + +/// 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_ignored, 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_ignored, 0); + let decoded = PngDecoder::new().decode(&png).expect("decode"); + assert_eq!(decoded.c2pa, meta.c2pa); + assert_eq!(decoded.c2pa_ignored, 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_ignored, 1); + let decoded = PngDecoder::new().decode(&png).expect("decode"); + assert_eq!(decoded.c2pa.as_deref(), Some(&b"first"[..])); + assert_eq!(decoded.c2pa_ignored, 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` 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) + ); +} + +/// 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 +/// 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_ignored, 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/colour_key.rs b/crates/gamut-png/tests/colour_key.rs new file mode 100644 index 00000000..b7e072e6 --- /dev/null +++ b/crates/gamut-png/tests/colour_key.rs @@ -0,0 +1,424 @@ +//! 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, 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. +/// +/// 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, 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. 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; + +/// 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) +} + +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() + .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 { + 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 { + 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 { + 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 +} + +/// 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"); +} + +/// 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. +/// +/// 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() + ); +} diff --git a/crates/gamut-png/tests/common/corpus.rs b/crates/gamut-png/tests/common/corpus.rs new file mode 100644 index 00000000..a4f3d34c --- /dev/null +++ b/crates/gamut-png/tests/common/corpus.rs @@ -0,0 +1,200 @@ +//! 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 +} + +/// 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 { + (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 f6c4030b..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]; @@ -146,3 +149,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", &[]), + ]) +} diff --git a/crates/gamut-png/tests/metadata.rs b/crates/gamut-png/tests/metadata.rs index 01e76200..501498a7 100644 --- a/crates/gamut-png/tests/metadata.rs +++ b/crates/gamut-png/tests/metadata.rs @@ -40,15 +40,18 @@ 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") .with_gamma(1.0 / 2.2) .with_srgb(SrgbIntent::RelativeColorimetric) + .with_cicp(9, 16, true) .with_chromaticities( (0.3127, 0.3290), (0.6400, 0.3300), @@ -63,8 +66,19 @@ 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 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)); @@ -87,9 +101,20 @@ 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_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(); @@ -98,15 +123,23 @@ 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_ignored, decoded.c2pa_ignored); assert_eq!(meta.texts, decoded.texts); assert_eq!(meta.gamma, decoded.gamma); 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/oracle.rs b/crates/gamut-png/tests/oracle.rs index 218914c3..0d438715 100644 --- a/crates/gamut-png/tests/oracle.rs +++ b/crates/gamut-png/tests/oracle.rs @@ -235,6 +235,134 @@ 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" + ); +} + +/// 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(); @@ -322,6 +450,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); @@ -406,9 +575,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 +620,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 +738,26 @@ 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. 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_eq!(dec.color_type, libpng_oracle::COLOR_PALETTE); - assert_eq!(dec.bit_depth, 2); + 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(); - 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(); @@ -635,3 +865,117 @@ 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"); + } +} + +/// 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 +} diff --git a/crates/gamut-png/tests/preservation.rs b/crates/gamut-png/tests/preservation.rs new file mode 100644 index 00000000..1592df7b --- /dev/null +++ b/crates/gamut-png/tests/preservation.rs @@ -0,0 +1,540 @@ +//! `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, 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, ErrorKind, ImageRef, Rgb8}; +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] = [ + 31_270, 32_900, 64_000, 33_000, 30_000, 60_000, 15_000, 6_000, +]; + +/// A source file carrying every metadata chunk the read side surfaces, built by hand. +/// +/// `extra` is appended before `IDAT`, so a case can add or replace a colour chunk without +/// rebuilding the pile. +fn source(extra: &[Vec]) -> 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", &compressed_xmp()), + 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) +} + +/// 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 { + 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(); + build(PngEncoder::new()) + .encode_to_vec(image) + .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. +/// 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.xmp_framing, meta.xmp_framing); + 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")); +} + +/// 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 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_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 +/// 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"); + 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 + .metadata_notices() + .contains(&MetadataNotice::NonRgbCicp), + "{:?}", + encoder.metadata_notices() + ); +} + +/// 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"); + + let encoder = PngEncoder::new().with_metadata(&meta); + assert!(re_encoded(|_| encoder.clone()).c2pa.is_none()); + assert_eq!( + encoder.metadata_notices(), + [MetadataNotice::C2paManifestStore] + ); +} + +/// 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()); +} + +/// §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() + ); +} + +/// 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 +/// 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. 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_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_text("Note", "before\0after") + .encode_to_vec(image) + .expect_err("refused"); + assert_eq!(error.kind(), ErrorKind::InvalidInput); + assert!( + error + .to_string() + .contains("may not contain a null character"), + "{error}" + ); +} + +/// 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 [`MetadataNotice::reason`] or its `Display` would otherwise leave the command +/// printing nothing at all. +#[test] +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 = MetadataNotice::NonRgbCicp.to_string(); + assert!(cicp.contains("cICP"), "{cicp}"); + assert!(cicp.contains("matrix coefficients"), "{cicp}"); + assert_eq!( + cicp, + 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:?}"); + } +} 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() diff --git a/crates/gamut-png/tests/size_contract.rs b/crates/gamut-png/tests/size_contract.rs new file mode 100644 index 00000000..1808e668 --- /dev/null +++ b/crates/gamut-png/tests/size_contract.rs @@ -0,0 +1,467 @@ +//! 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, Rgb16, Rgba8}; +use gamut_png::{FilterStrategy, Level, PngEncoder, deconstruct}; + +/// One case's size budget against libpng at zlib level 9. +struct Budget { + /// 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, + /// 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". + /// + /// 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 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, 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, 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 +/// -- 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", + fixture: "gradient_rgb8", + side: 128, + depth: 8, + 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", + fixture: "photo_rgb8", + side: 128, + depth: 8, + 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, so it carries 13% headroom \ + where the others carry 5%.", + }, + Budget { + name: "noise_rgb8", + fixture: "noise_rgb8", + side: 128, + depth: 8, + 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 2% margin covers stored-block framing only.", + }, + Budget { + name: "grey_as_rgb8", + fixture: "grey_as_rgb8", + side: 128, + depth: 8, + 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", + fixture: "flat_rgba8", + side: 128, + depth: 8, + 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. 10% headroom because at ~100 bytes total a single \ + byte moves the ratio by about a percent.", + }, + Budget { + name: "sprite_rgba8", + fixture: "sprite_rgba8", + side: 128, + depth: 8, + cleanup: false, + max_ratio: 0.99, + measured: 0.963, + 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, + depth: 8, + 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", + fixture: "palette64_rgba8", + side: 128, + depth: 8, + 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 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 -- 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", + fixture: "palette64_rgba8", + side: 128, + depth: 8, + cleanup: true, + 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: "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, + 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.", + }, +]; + +/// 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(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), + "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), + // 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}"), + } +} + +/// Encodes at the crate's smallest-output settings. +/// +/// `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, depth: u8, side: u32, cleanup: bool) -> Vec { + let encoder = PngEncoder::new() + .with_compression(Level::Best) + .with_filter(FilterStrategy::BruteForce) + .with_auto_reduce(true) + .with_transparent_cleanup(cleanup); + let dims = Dimensions::new(side, side).expect("valid dimensions"); + let mut out = Vec::new(); + 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 { + 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, depth: u8, side: u32) -> Vec { + let color_type = if channels == 3 { + libpng_oracle::COLOR_RGB + } else { + libpng_oracle::COLOR_RGBA + }; + libpng_oracle::encode( + samples, + side, + side, + color_type, + depth, + &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.fixture, 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. + 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 {}", + 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", + "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.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 {}", + budget.name, + ours.len(), + theirs.len(), + ); + } +} + +#[test] +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, 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"), + ); + + 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 codestream is {} 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.fixture, budget.side); + 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); + } +} + +#[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.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 \ + kept the uncleaned encoding", + budget.name, + cleaned.len() - plain.len(), + plain.len(), + cleaned.len(), + ); + } +} diff --git a/crates/gamut-png/tests/transparent_cleanup.rs b/crates/gamut-png/tests/transparent_cleanup.rs new file mode 100644 index 00000000..3eda1a3b --- /dev/null +++ b/crates/gamut-png/tests/transparent_cleanup.rs @@ -0,0 +1,309 @@ +//! `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, GrayAlpha16, ImageRef, Rgba8, Rgba16}; +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)); +} + +// --- 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" + ); +} diff --git a/docs/README.md b/docs/README.md index 25725652..16f25c7b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,6 +9,8 @@ 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/benchmarking.md b/docs/benchmarking.md new file mode 100644 index 00000000..d16542b5 --- /dev/null +++ b/docs/benchmarking.md @@ -0,0 +1,128 @@ +# 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 | +| 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 +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/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. diff --git a/docs/testing.md b/docs/testing.md index c38d701a..631126ed 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -77,7 +77,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 @@ -194,7 +194,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` ☐ | @@ -228,3 +228,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. 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)"