Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
fc9e589
feat(png): account every byte of a PNG with deconstruct
justin13888 Sep 1, 2026
27359a2
refactor(png): expose the encoder stages behind test-support
justin13888 Sep 1, 2026
92a1474
chore(png): benchmark encode size, bpp and per-stage throughput
justin13888 Sep 1, 2026
78466a1
test(png): pin the output size against libpng at maximum compression
justin13888 Sep 1, 2026
ded5128
feat(png): opt-in cleanup of invisible pixel colour
justin13888 Sep 1, 2026
6b31ab9
fix(png): keep the palette only when it is actually smaller
justin13888 Sep 1, 2026
85beb2f
perf(png): accelerate CRC-32 and the scanline filter loops
justin13888 Sep 1, 2026
fddc749
docs: record the gamut-png efficiency baseline and the benchmarking c…
justin13888 Sep 1, 2026
1cc51fd
test(png): close the mutation gaps in the new efficiency code
justin13888 Sep 1, 2026
f360e51
feat(cli): inspect PNG byte accounting
justin13888 Sep 1, 2026
54eb160
feat(png): reduce binary alpha to a tRNS colour key
justin13888 Sep 1, 2026
1529ab0
docs(png): record the colour key in the axis scorecard
justin13888 Sep 1, 2026
364efa9
feat(png): order the palette, and close the colour-key mutation gaps
justin13888 Sep 1, 2026
cb1c377
docs(png): refresh the efficiency tables after palette ordering
justin13888 Sep 1, 2026
4fa858b
feat(png): entropy and bigram filter heuristics
justin13888 Sep 1, 2026
319c345
fix(png): emit a filtered row when every filter candidate ties
justin13888 Sep 1, 2026
ea4a9e2
refactor(png): read the bigram index as one big-endian pair
justin13888 Sep 1, 2026
73e9c0b
refactor(png): make the colour-key arms total instead of unreachable
justin13888 Sep 1, 2026
0c27e29
test(png): separate palette ordering from discovery order
justin13888 Sep 1, 2026
934a76f
perf(png): index the chunk tally by type
justin13888 Sep 1, 2026
25b1a14
feat(png): clean invisible colour on the 16-bit paths too
justin13888 Sep 1, 2026
dbb0d80
docs: settle the counter rule, the png authority row and two counts
justin13888 Sep 1, 2026
82220cc
fix(png): budget the quantity the decoder budgets
justin13888 Sep 1, 2026
c16ac22
test(png): cover the greyscale colour key
justin13888 Sep 1, 2026
ef73616
test(png): pin the size race a colour key can lose
justin13888 Sep 1, 2026
589261f
test(png): re-pin sub-byte indexed auto-reduce
justin13888 Sep 1, 2026
8dcac02
fix(png-cli): say why the filter scan was skipped
justin13888 Sep 1, 2026
0d680a8
docs(cli): state what inspect's exit code means per format
justin13888 Sep 1, 2026
e858818
refactor(png): delete choose_min_sum_abs
justin13888 Sep 1, 2026
9ca0f19
test(png): derive every size budget from its measurement
justin13888 Sep 1, 2026
8e9f038
fix(png): race the cleaned encoding instead of assuming it wins
justin13888 Sep 1, 2026
448c3f6
docs(png): say which efficiency tables are gated and which only report
justin13888 Sep 2, 2026
c831147
style(png): drop a stray blank line in the size contract
justin13888 Sep 2, 2026
c04b8d4
chore: merge origin/master into feat/224-png-encoder-efficiency
justin13888 Sep 2, 2026
e2c38fb
test(png): pin the cleanup tie-break and the entropy weighting
justin13888 Sep 2, 2026
7593fe5
feat(png): bound the deconstruct walk and name what it actually read
justin13888 Sep 2, 2026
a0bde8e
test(png): pin the walk's ceilings, its saturation and the unread ver…
justin13888 Sep 2, 2026
cd70f78
fix(cli): gate inspect on what it read, and bound the lists it prints
justin13888 Sep 2, 2026
b20f9d4
feat(png): seal FilterStrategy, and clear only the bigram words a row…
justin13888 Sep 2, 2026
5a363fc
refactor(png): drop a palette sort key that cannot change the order
justin13888 Sep 2, 2026
bec1e5b
test(png): pin the greyscale colour key the race declines
justin13888 Sep 2, 2026
423a0e6
docs(png): correct the cost model and the size claim against the encoder
justin13888 Sep 2, 2026
332af8d
docs: record the crc32fast approval for gamut-png
justin13888 Sep 2, 2026
97567f5
test(png): kill the five mutants the new walk code left alive
justin13888 Sep 2, 2026
9d7f770
chore: merge origin/master into feat/224-png-encoder-efficiency
justin13888 Sep 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <triple>` | 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 |
Expand Down
264 changes: 257 additions & 7 deletions crates/gamut-cli/src/commands/inspect.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,40 @@
//! `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.
//!
//! # 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.
//!
//! 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;

Expand All @@ -21,7 +52,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)]
Expand All @@ -35,6 +66,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.
Expand All @@ -56,9 +89,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);
Expand All @@ -77,8 +118,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
Expand Down Expand Up @@ -326,15 +374,216 @@ fn print_ranges(label: &str, ranges: &[(u64, u64)]) {

/// Prints a pre-formatted line list under `label`, truncating past [`MAX_LIST`].
fn print_lines(label: &str, lines: &[String]) {
if lines.is_empty() {
print_lines_of(label, lines, lines.len());
}

/// [`print_lines`], where `lines` is already truncated and `total` is how many there really are.
///
/// Splitting the count from the list is what lets a caller whose list length is chosen by the
/// input build only the lines it will print while still reporting the true total.
fn print_lines_of(label: &str, lines: &[String], total: usize) {
if total == 0 {
return;
}
println!(" {label}: {}", lines.len());
println!(" {label}: {total}");
for line in lines.iter().take(MAX_LIST) {
println!(" - {line}");
}
if lines.len() > MAX_LIST {
println!(" … and {} more", lines.len() - MAX_LIST);
if total > lines.len() {
println!(" … and {} more", total - lines.len());
}
}

/// 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<String> = 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::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",
}
}

Expand All @@ -343,6 +592,7 @@ fn format_name(format: Format) -> &'static str {
match format {
Format::Tiff => "TIFF",
Format::Dng => "DNG",
Format::Png => "PNG",
}
}

Expand Down
2 changes: 1 addition & 1 deletion crates/gamut-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Loading
Loading