Skip to content

feat(png)!: preserve metadata across a re-encode, and name what could not come along - #550

Open
justin13888 wants to merge 14 commits into
feat/440-png-cabxfrom
fix/483-png-metadata-preservation
Open

justin13888 wants to merge 14 commits into
feat/440-png-cabxfrom
fix/483-png-metadata-preservation

Conversation

@justin13888

@justin13888 justin13888 commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Summary

Closes the metadata gap in gamut-png and the two spec defects issue #483 names, established
against the vendored PNG 3rd edition (references/png/png-3.html) rather than the issue's text.

Metadata preservation. PngEncoder::with_metadata(&PngMetadata) and
with_metadata_from(&DecodedPng) carry a file's eXIf, iCCP, XMP, text annotations and colour
chunks into the encoder that rewrites its pixels. Previously nothing connected the read side to
the write side, so every re-encode dropped all of it. gamut convert now uses this on the PNG
output path, with --strip-metadata as 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.

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. The
XMP packet leaves the read side through its own field rather than as a TextChunk, so the framing
that field cannot hold — §11.3.3.4's compression flag, language tag and translated keyword —
travels beside it in XmpFraming. Without it the fixture's 354-byte iTXt came back out at
3 734 bytes where keeping the flag writes 352 — a factor of 10.6 — and the language tag and
translated keyword were a second, separate loss of the same defect. The packet is a single-value payload: setting it twice
sets it once, because a second iTXt under §11.3.3.1 Table 21's reserved keyword is one this
crate's own reader discards.

What refuses, what is reported, and why the line is there. §15 gives the BCP 14 keywords force
"when, and only when, they appear in all capitals". Every statement §11.3.3.1 makes about a
keyword's shape is lowercase, and five of those 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. So the writer is no longer stricter than its own reader: those keywords are written
verbatim and reported. A keyword no chunk can hold (outside Latin-1, or outside the 1–79 bytes all
three chunks fix), a text string holding a null, a non-ASCII iTXt language tag and an XMP packet
that is not UTF-8 are dropped and reported. Only a null in a keyword — or in an iTXt
translated keyword — refuses the encode, because those are the fields a null separator ends, so
the chunk would re-parse as a different annotation. A null in the text string re-frames
nothing (§11.3.3.2: "The text string is not null-terminated (the length of the chunk defines the
ending)") and this crate's own reader hands such a text back whole, so refusing it made the writer
stricter than its own reader; libpng truncates it at the null, so it is not written verbatim
either. MetadataNotice::carried() tells a caller which of the two happened — derived from
whether the annotation was emitted, so a "written, but…" notice is never reported for one nothing
was written for; gamut convert prints both, worded differently.

The sRGB-beside-iCCP refusal an earlier draft of this branch carried was removed on the
same argument: §5.6 Table 5 and §11.3.2.5 say only that the pair "should not" appear together —
lowercase — while §4.3 Table 1 presupposes it and ranks the chunks (cICP 1, iCCP 2, sRGB 3,
cHRM+gAMA 4). Both are written and a reader takes the one it can use; dropping either would throw
away colour information the source carried. The ranking is a colour-management decision, and it is
left to a CMM rather than pre-empted by the encoder.

Defect 1 — tEXt/zTXt carried UTF-8. §11.3.3.1 restricts a keyword to Latin-1 in all three
text chunks; §11.3.3.2 says a tEXt text string "is interpreted according to the Latin-1
character set"; §11.3.3.3 says inflating a zTXt "yields Latin-1 text that is identical to the
text that would be stored in an equivalent tEXt chunk". write_text pushed the Rust String's
UTF-8 bytes into all of these, so every code point above U+007F was stored as mojibake — é
(U+00E9) became C3 A9, which a conforming reader renders é. Text and keyword are now
converted to Latin-1 once, when the caller sets them, and the entry is stored in the byte form its
chunk carries, so the wrong encoding is unrepresentable. Text that Latin-1 cannot encode at all is
promoted to iTXt exactly as §11.3.3.2 directs, keeping the caller's compression choice via
§11.3.3.4's own flag.

Defect 2 — iTXt lost its language tag and translated keyword, the two fields that make it
international, and its compression flag. All three are now carried, for ordinary annotations and
for the XMP packet alike.

A specification contradiction, recorded rather than resolved silently. §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 one sentence after naming the same
character set: "The text string may contain any Latin-1 character." Both are quoted in
ancillary.rs and in STATUS.md. The more specific and more permissive clause is taken, so a
conforming annotation is no longer silently promoted to a different chunk type — which would have
contradicted the identity claim above.

with_cicp (§11.3.2.6) is added because without it preservation would silently drop the
highest-precedence colour chunk (§4.3 Table 1, priority 1) from any file that carries one. It
takes no matrix-coefficients argument: §11.3.2.6 states "RGB is currently the only supported color
model in PNG, and as such Matrix Coefficients shall be set to 0."

Validation

Run in the lane's worktree on fix/483-png-metadata-preservation. Workspace-wide gates ran inside
a memory-capped scope (MemoryMax=16G, CARGO_BUILD_JOBS=2). mise run fmt-check needs
__CARGO_TEST_ROOT=$(git rev-parse --show-toplevel) in a nested worktree, because the task loops
over every tooling/*/Cargo.toml and at least one has no [workspace] table; this is an
environment artefact, not a manifest change.

Command Result
cargo test -p gamut-png --all-features pass — 190+27+9+23+16+9+9+12+10+21+18+7+5+9+7 tests, 0 failed
cargo clippy -p gamut-png -p gamut-cli --all-targets --all-features -- -D warnings pass
__CARGO_TEST_ROOT=$(git rev-parse --show-toplevel) mise run fmt-check pass
mise run check-tests pass
mise run check-commits pass
convco check origin/feat/440-png-cabx..HEAD pass — no errors in 11 commits
mise run lint pass — workspace clippy, -D warnings
mise run test pass — 210 test binaries, 0 failed
GAMUT_MUTANTS_BASE=origin/feat/440-png-cabx mise run mutants-diff pass — 71 mutants, 61 caught, 0 missed, 0 timeouts, 10 unviable

The compressed-XMP defect, measured on tests/preservation.rs's fixture: the source iTXt
payload is 354 bytes; before this change the carry rewrote it as 3 734 bytes (a factor of
10.6) with the language tag and translated keyword dropped; after it, 352 bytes with both
strings intact. a_compressed_xmp_packet_goes_back_into_a_compressed_itxt measures the pair
rather than a fixed threshold, so the claim is the inflation the flag prevents.

No Cargo.toml changed, so check-release-deps, check-ffi-features and check-ffi-header were
not required.

Round 5 (commits ad178e05, a7f57fd3)

Command Result
cargo test -p gamut-png --all-features pass — 193+27+9+23+16+9+9+12+10+21+19+7+5+9+7 tests, 0 failed
cargo clippy -p gamut-png -p gamut-cli --all-targets --all-features -- -D warnings pass
__CARGO_TEST_ROOT=$(git rev-parse --show-toplevel) mise run fmt then mise run fmt-check pass
mise run check-tests pass
convco check origin/feat/440-png-cabx..HEAD pass — no errors in 13 commits
mise run lint pass — workspace clippy, -D warnings, in a 16 GiB scope
mise run test pass — 210 test binaries, 0 failed, in a 16 GiB scope
GAMUT_MUTANTS_BASE=origin/feat/440-png-cabx mise run mutants-diff pass — 72 mutants, 62 caught, 0 missed, 0 timeouts, 10 unviable, base origin/feat/440-png-cabx (the stacked base, not master)

Correction to the measurement stated above. The paragraph beginning "The compressed-XMP
defect, measured on tests/preservation.rs's fixture" reads "the source iTXt payload is 354
bytes; before this change the carry rewrote it as 3 734 bytes (a factor of 10.6) with the
language tag and translated keyword dropped; after it, 352 bytes with both strings intact".
All three figures are real and re-measured this round, but the prose pairs them wrongly: 10.6 is
3 734 ÷ 352, not 3 734 ÷ 354 (which is 10.5), and the 3 734-byte figure is measured by the test
with the two strings present — it clears only the compression flag. The compression flag's cost
is 352 → 3 734 bytes; the language tag and translated keyword are a separate loss of the same
defect, pinned by a separate test. Re-measured on b4fa5ada: source payload 354 bytes, carried
compressed 352 bytes, same carry with the flag cleared 3 734 bytes.

The null-in-a-text-string defect, executed before and after (b4fa5ada), on a source PNG
built chunk by chunk:

Source chunk Reader returns Before After
tEXt Comment\0val\0ue keyword Comment, text val\0ue Error::InvalidInput: "a keyword or text string may not contain a null character" annotation dropped, TextStringNull reported, encode succeeds
iTXt Comment\0\0\0\0\0val\0ue same same error same
zTXt Comment\0\0 + zlib(val\0ue) same same error same

Round 5, continued (commit af5cbdbc). Two inaccuracies this round's own repair introduced,
closed in a commit of their own.

Command Result
cargo test -p gamut-png --all-features pass — 193+27+9+23+16+9+9+12+10+21+19+7+5+9+7 tests, 0 failed
cargo clippy -p gamut-png -p gamut-cli --all-targets --all-features -- -D warnings pass
cargo doc -p gamut-png --no-deps --document-private-items --all-features pass — no warning at either edited site; the crate's pre-existing unresolved-link warnings (BCP47, text_repertoire) are untouched and not from this change
__CARGO_TEST_ROOT=$(git rev-parse --show-toplevel) mise run fmt then mise run fmt-check pass
mise run check-tests pass
convco check origin/feat/440-png-cabx..HEAD pass — no errors in 14 commits
GAMUT_MUTANTS_BASE=origin/feat/440-png-cabx mise run mutants-diff pass — 72 mutants, 62 caught, 0 missed, 0 timeouts, 10 unviable, base origin/feat/440-png-cabx

mise run lint and mise run test were not re-run for this commit: it changes two doc comments and
one user-visible string with no assertion on it, and cargo clippy over both affected crates plus
the full gamut-png suite and the mutation gate cover exactly what it touches. Stated as a
subset, not as the whole.

Correction to the round-5 heading above. It reads "### Round 5 (commits ad178e05,
a7f57fd3)". a7f57fd3 was amended before it was ever pushed and does not exist in this
repository; the commit that heading means is b4fa5ada, whose content is the same plus one
re-wrapped STATUS.md line. The heading is left as written; this entry is the pointer.

Coverage (test gate) was still pending ~35 minutes after b4fa5ada was pushed, having been
non-terminal for over two and a half hours at round 4 with six concurrent runs queued. That is a
starved queue, not a failing job: every other required check — Format & Metadata, Clippy &
Doctests, Incremental (0–3) — is terminal and passing. It is recorded here as an observation, and
this round did not wait on it.

The carried() defect, executed on the same head: a carry whose TextChunk has a keyword
outside Latin-1 and a language tag outside BCP 47 reported [TextKeywordNotLatin1, ItxtLanguageTag] with zero iTXt chunks written, and ItxtLanguageTag.carried() is true
so a CLI printed "carried with a caveat" for an annotation that was not written. After: only
TextKeywordNotLatin1 is reported.

Risks and rollout

  • Breaking, and marked so. DroppedMetadata is renamed MetadataNotice (six new variants,
    permanent append-only discriminants), and PngEncoder::dropped_metadata() -> &[DroppedMetadata]
    becomes metadata_notices() -> Vec<MetadataNotice>. PngMetadata and DecodedPng gain an
    xmp_framing field; both are #[non_exhaustive], so only exhaustive construction inside this
    workspace is affected. Neither type had shipped in a release, but the marker stays: over-signalling
    a version bump is recoverable, under-signalling is not.
  • An encode that used to fail now succeeds with a notice. A keyword outside §11.3.3.1's
    repertoire, length or spacing rules, a non-ASCII iTXt language tag and a non-UTF-8 XMP packet no
    longer return Error::InvalidInput. A caller that relied on the refusal must read
    metadata_notices() instead. The reverse — a caller who could not convert a file at all — is the
    case this change exists for.
  • An encode that used to succeed now fails only for a null byte in a keyword, or in an iTXt
    translated keyword. A null in a text string, which earlier commits on this branch also refused,
    now drops the annotation and reports TextStringNull instead — it was the last shape that made a
    re-encode fail on a file this crate's own reader accepts.
  • Text with characters above U+007F lands as Latin-1 bytes in tEXt/zTXt where it used to land
    as UTF-8 mojibake, and text Latin-1 cannot encode lands in iTXt. Both are the spec's encoding;
    the previous bytes were unreadable by any conforming reader. A control character or U+00A0 in a
    text string now stays in tEXt rather than promoting, which changes the chunk type such an
    annotation is written to relative to the previous commit on this branch.
  • A compressed XMP packet is now re-encoded compressed, so a re-encoded file carrying one is
    smaller than the previous commit on this branch produced, not larger.
  • with_metadata never carries the C2PA manifest store: a store is 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.
  • pHYs, tIME, sBIT and bKGD are not part of PngMetadata/DecodedPng, so they cannot be
    preserved. The XMP packet is emitted first among the annotations regardless of where its chunk
    sat in the source, and its two read-side fields can be set inconsistently by a caller building a
    PngMetadata by hand. Both are gamut-png: the XMP packet is an iTXt held outside the text list, so its framing needs a parallel field #600.
  • 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. tooling/libpng-oracle: expose bKGD/sBIT and a warning count so ancillary-chunk acceptance is oracle-testable #502, tooling/libpng-oracle: transcode text payloads to Latin-1 so tEXt/zTXt can be oracle-generated #571 and tooling/libpng-oracle: read back text and colour chunks so metadata preservation is oracle-testable #572 are what would make it
    differential.

Issue

Refs #483. Two remainders this run declined are filed:

  • #600 — consolidating the XMP packet into
    the text list, which would close the parallel-field, single-value and chunk-order seams together
    but reshapes a public type on a crate about to be version-bumped;
  • #604 — six files cite tRNS as §11.3.2.1,
    which the vendored spec gives to cHRM. Found while auditing this change's citations; the sites
    are in the encoder, the reducer and two test suites, none of them on this change's surface;
  • #619 — the iCCP profile name has, word for
    word, the same §11.3.2.3 rules as a text keyword and gets none of this machinery: executed, a
    non-Latin-1 name becomes mojibake and an empty, over-long or null-bearing name loses the profile
    silently. Pre-existing, unreachable through the carry, and outside this manifest.

Stacked on

Based on origin/feat/440-png-cabx at e1dddb76, the head of PR #511 (open, not merged by
this run). #511 is itself stacked on PR #485 (feat/224-png-encoder-efficiency). Review this diff
against that base, not against master.

Decisions taken

No human approved this plan. This was an unattended run; the record below is what a human reads
afterwards. The user's own words declaring the run unattended:

Resolve all PRs and iteratively and properly and exhaustively resolve all issues that do not
require extensive R&D and experimentations. List the issues and PRs are you covering (do need
to mention what you're skipping)

The frozen record this lane was given, verbatim:

Issue 483  Plan: v1  Branch: fix/483-png-metadata-preservation  Base: PR #511 head e1dddb76
Touches: crates/gamut-png/src/encoder.rs (with_metadata_from(&DecodedPng); refuse sRGB+iCCP
         together with Error::InvalidInput; with_text validates Latin-1 and routes non-Latin-1
         to iTXt), crates/gamut-png/src/ancillary.rs, crates/gamut-png/tests/,
         crates/gamut-cli/src/commands/convert.rs (pass metadata through), STATUS.md
Will not: change the C2PA/caBX behaviour #511 just landed; touch the palette or effort work
          (#482, #484 are separate entries); add an external dependency
Settled: S1 (no new dependency), S3 (tests follow docs/testing.md)

Decisions taken.
1. Boundary: the issue's item 1 plus the two spec defects it names. Nothing else.
2. Default policy: preserve everything — that is the status quo and nothing is stripped.
   A `--strip-metadata` CLI flag is the opt-out.
   Rejected: strip by default, which is lossy for colour.
   Reverses: flip the flag's default.
   Filed: -

Appended by this lane:

  1. Two entry points, not one. with_metadata(&PngMetadata) alongside the record's
    with_metadata_from(&DecodedPng), over one private borrowed view.
    Rejected: only &DecodedPng, which forces a full pixel decode on a caller that used the
    pixel-free metadata() entry point — which is precisely what gamut convert does, since it
    decodes through the shared RGBA path and never holds a DecodedPng.
    Rejected: only &PngMetadata plus a cloning accessor on DecodedPng, which copies every
    payload twice.
    Reverses: delete one method; the other keeps working.

  2. with_cicp is added rather than the gap documented. The record's policy is "preserve
    everything"; without a cICP setter, with_metadata would silently drop the highest-priority
    colour chunk (§4.3 Table 1) of any file that has one, which makes the policy untrue for
    exactly the files it matters most for. Four bytes, fully specified by §11.3.2.6.
    Rejected: document the hole and file it — a preservation feature whose headline gap is the
    top-priority colour chunk is not the feature.
    Reverses: remove the setter and skip the field in with_metadata_view.

  3. A non-Latin-1 text keyword refuses the encode; it is not dropped and not transliterated.
    §11.3.3.1 binds keywords in all three text chunks, so unlike the text — which §11.3.3.2 routes
    to iTXt — there is no chunk that can carry it.
    Rejected: drop the annotation silently, which is the same silent metadata loss this issue is
    about.
    Reverses: turn the validate arm into a skip.

  4. The full §11.3.3.1 keyword syntax rules are not enforced — printable subset (0x20–0x7E,
    0xA1–0xFF), no leading/trailing/consecutive spaces, no U+00A0, 1–79 bytes. The record scopes
    this lane to Latin-1 representability, and enforcing the rest would make preservation fail on
    real-world files whose keywords carry a trailing space. Filed as a follow-up.
    Reverses: extend the validate predicate.

  5. The CLI carries metadata only PNG→PNG. gamut convert reads the input's PNG metadata with
    gamut_png::metadata, which errors on any non-PNG input and is then skipped. Mapping
    JPEG/WebP/JXL metadata into PNG chunks is a cross-format job with its own decisions and is
    outside this lane's manifest (convert.rs only). Filed as a follow-up.
    Reverses: widen the sniff once the mapping exists.

Appended by this lane in round 3, after review:

  1. A compressed XMP packet keeps its compression flag, language tag and translated keyword.
    The packet leaves the read side through its own field rather than as a TextChunk, so
    parse_itxt bound §11.3.3.4's three framing values and discarded them for that one keyword;
    the writer, with no chunk-kind to consult, always emitted flag 0 and two empty strings.
    Measured on the fixture added with this change: a 354-byte iTXt was rewritten as 3 734 bytes,
    a factor of 10.6. XmpFraming now travels beside the packet on both read surfaces.
    Rejected: infer the flag from the packet's size, which guesses at what the file already stated.
    Rejected: drop the framing and document it, which is what the previous round did without
    noticing, and which makes the "identity, not just content" claim untrue for the largest payload
    the path carries.
    Reverses: -
    Filed: -

  2. The XMP packet is a single-value payload: a carry replaces one already set.
    add_xmp appended, so with_xmp(..).with_metadata(..) wrote two iTXt chunks under
    §11.3.3.1 Table 21's one reserved keyword, and this crate's first-wins reader then discarded
    the carried packet.
    Rejected: leave it and document that a caller must not do both — a silent loss inside the
    feature built to end silent loss.
    Reverses: -
    Filed: #600 — moving the packet into
    texts would fix this, the parallel-field seam and the chunk-order seam together, but it
    reshapes a public type on a crate about to be version-bumped, so it is a decision of its own.

  3. Only a null byte refuses an encode. §11.3.3.1's keyword clauses report instead.
    §15 gives the BCP 14 keywords force "when, and only when, they appear in all capitals", and
    every statement §11.3.3.1 makes about a keyword's shape is lowercase — the same argument that
    justified carrying sRGB and iCCP together. Five keyword shapes this crate's reader
    accepts and returns unchanged (leading space, trailing space, consecutive spaces, C0/C1
    control, U+00A0) were refused on re-encode, so a conversion failed over a file whose pixels
    were fine and the only escape discarded the file's ICC profile too. Those keywords are now
    written verbatim and reported; a keyword no chunk can hold, a non-ASCII iTXt language tag
    and a non-UTF-8 XMP packet are dropped and reported. A null still refuses, because the chunk
    would re-parse as a different annotation.
    Rejected: keep the refusals and add an opt-out flag — a second knob for a clause the
    specification does not bind.
    Rejected: drop the offending annotation silently, which is the silent loss this issue is about.
    Reverses: decision 6, which predicted exactly this failure ("enforcing the rest would make
    preservation fail on real-world files whose keywords carry a trailing space") and was then
    contradicted by the code without the record being amended.
    Filed: -

  4. The reporting channel is renamed, because it no longer reports only drops.
    DroppedMetadata becomes MetadataNotice and dropped_metadata() becomes
    metadata_notices(), with MetadataNotice::carried() separating a payload left behind from
    one written with a caveat. gamut convert words the two cases differently.
    Rejected: keep the name and file TextKeywordSpacing under DroppedMetadata for an
    annotation that is written — an enum whose name contradicts three of its variants.
    Rejected: report only the drops and stay silent about the written-verbatim cases, which leaves
    a caller unable to learn that its output carries a keyword some readers reject.
    Reverses: -
    Filed: -

  5. A tEXt text string may hold any Latin-1 character (§11.3.3.2), not only the printable
    subset (§11.3.3.1's closing paragraph).
    The two clauses contradict each other one sentence
    apart; the more specific and more permissive is taken, so a conforming annotation is no longer
    silently promoted to a different chunk type — which contradicted the identity claim this work
    makes. Both halves are quoted from the vendored text in ancillary.rs and STATUS.md rather
    than one being picked in silence. The keyword rule is unchanged, being specific to keywords
    and shared by all three chunks.
    Rejected: keep the tighter reading because promotion is lossless — it is lossless in
    characters and not in chunk type, which is precisely what TextChunkKind exists to preserve.
    Reverses: part of the reasoning behind decision 5's promotion rule, not its outcome.
    Filed: -

  6. The colour-chunk ranking stays with a colour-management module, not this encoder.
    with_metadata writes every colour chunk the source carried and resolves nothing. §4.3 Table 1
    ranks them for a reader; which one to honour depends on whether the reader has a CMM at all,
    which an encoder cannot know. The carry's justification is therefore a claim about other
    readers — that a conforming one will pick correctly — not about this crate, whose own reader
    surfaces all of them without ranking.
    Rejected: drop the lower-priority chunk at encode time, which throws away colour information
    the source carried and bakes a reader's capability into a writer.
    Reverses: -
    Filed: -

  7. The four behavioural findings land as one commit, not four. They are entangled in
    ancillary.rs and encoder.rs — the notice channel must exist before add_xmp can report
    through it, and gamut-cli cannot compile across the rename — so a four-commit series would
    need reconstructed intermediate states that do not correspond to any state this work was ever
    in, and two of the four would not build. The code lands as one commit whose body names each
    defect and its mechanism; the record, the added parser tests and the colour-ranking note
    follow as three commits of their own.
    Rejected: four commits that individually fail to build, which breaks bisect for a cosmetic
    gain.
    Reverses: -
    Filed: -

  8. Only the citations on this change's own surface are corrected. Three read-surface doc
    comments named the wrong clause against references/png/png-3.html — cICP as §11.3.2.5
    (which is sRGB), sRGB as §11.3.2.4 (which is sBIT), eXIf as §11.3.4.4 (which is sPLT) — and
    are fixed here. A fourth, wider error found in the same audit — tRNS cited as §11.3.2.1,
    which the spec gives to cHRM, in six files across the encoder, the reducer and two test
    suites — is filed rather than swept up.
    Rejected: fix all of them, which turns a metadata-preservation review into a crate-wide
    citation sweep across files this change does not otherwise touch.
    Reverses: -
    Filed: #604.

Appended by this lane in round 5, after review:

  1. A null in a text string drops the annotation and reports it; it does not refuse the
    encode.
    The two clauses cited for the refusal deny the mechanism given for it: §11.3.3.2
    says "The text string is not null-terminated (the length of the chunk defines the ending)"
    and §11.3.3.4 "The text, unlike other textual data in this chunk, is not null-terminated; its
    length is derived from the chunk length". The text is last in all three chunks, so a null in
    it re-frames nothing — unlike a null in the keyword, which is the field a null separator ends
    and which still refuses, as does one in an iTXt translated keyword. This crate's own reader
    hands such a text back whole (executed on tEXt, iTXt and a compressed zTXt), so it was
    the last shape that made a re-encode fail on a file gamut itself accepts — the failure class
    decision 10 exists to eliminate, under the rule decision 10 states.
    Rejected: keep the refusal — it is the position the review refuted, and its stated mechanism
    is false.
    Rejected: write the text verbatim with a "written, but…" notice, as §11.3.3.1's advisory
    keyword shapes are written. libpng truncates such a text at the null, so the chunk would hold
    one annotation for this crate and a shorter one for libpng; a payload whose content depends on
    who reads it is not preservation. Unlike the keyword shapes, this clause is a flat prohibition
    on content, not a recommendation about form.
    Reverses: decision 10's "a null still refuses" for the text string only; the keyword and
    translated-keyword refusals stand.
    Filed: -

  2. carried() is derived from the entry's emit flag, not from the notice variant alone.
    itxt_entry pushes the language-tag notice after text_entry has already cleared the emit
    flag for a keyword no chunk can hold, so metadata_notices() reported a notice whose
    carried() is true for an annotation of which zero chunks were written (executed). The
    variant cannot know this — it crosses the C ABI as a plain integer and must stay a pure
    function of itself — so text_notices filters instead: an entry that was not emitted reports
    only the notices that explain the drop. That is the root, and it also closes the row the
    review's mechanically derived keyword table omits.
    Rejected: pair each notice with a carried bit in the returned type, which reshapes a public
    API over a defect that is a reporting-order bug, and leaves MetadataNotice::carried() either
    redundant or contradicting its neighbour.
    Rejected: suppress the language-tag notice at the push site in itxt_entry, which fixes the
    one shape the review found and leaves every future "written, but…" notice free to be recorded
    on a dropped entry.
    Reverses: -
    Filed: -

  3. The iCCP profile name is filed, not fixed here. §11.3.2.3 gives it word for word the
    keyword's rules — 1–79 bytes, printable Latin-1, no leading/trailing/consecutive spaces — and
    the writer applies none of them: executed, a non-Latin-1 name becomes mojibake and an empty,
    over-long or null-bearing name loses the profile silently, with no notice. It is pre-existing,
    unreachable through the carry (a name read out of a file has already passed the reader's 1–79
    byte split), and outside this manifest.
    Rejected: fix it here, which widens a metadata-preservation review into the colour-profile
    writer on a branch already three rounds long.
    Rejected: leave it unfiled, when the notice channel it needs now exists and the executed
    evidence is in hand.
    Reverses: -
    Filed: #619, with the executed table.

  4. A keyword outside §11.3.3.1's repertoire is reported as "non-conforming per §15.3.1", not as
    something "another reader may reject".
    The review established that no real reader rejects a
    deviant keyword — libtiff's and libpng's read paths never validate one — so the old wording was
    both vaguer and less true than the conformance statement the specification supports: §15.3.1
    requires that "All field values in the PNG datastream obey the relationships specified in this
    specification".
    Rejected: keep "another reader may reject it", which asserts about readers what the reviewer
    disproved by reading them.
    Reverses: -
    Filed: -

  5. with_cicp's "unlike the sRGB/iCCP pair" clause is deleted rather than qualified. It
    is residue of a refusal this branch removed and it contradicts decision 13 and with_srgb's
    own doc two methods above; the sentence says everything it needs to without it.
    Rejected: rewrite it to explain the pair, duplicating with_srgb's paragraph at a method
    about a different chunk.
    Reverses: -
    Filed: -

  6. TextStringNull's user-visible line names both payloads; the XML consequence goes in the
    variant's doc.
    The XMP packet is carried as an iTXt under §11.3.3.1 Table 21's reserved
    keyword, so it reaches the same notice as an ordinary annotation, and the line a caller reads
    said "text annotation" for something that is not one. Of the two ways to close that, widening
    the line is the one that reaches the caller — a doc comment reaches only whoever is reading
    the source — so the line now names both, and the variant's doc carries what the second path
    additionally means: XML 1.0 does not admit U+0000, so a packet that reaches this notice is
    already not well-formed XML, not merely unwritable.
    Rejected: a distinct notice variant per payload, which splits one clause across two
    discriminants for a case only a corrupt packet reaches.
    Rejected: document the XMP path and leave the line alone, which leaves the wrong payload named
    in the one string a user actually sees.
    Reverses: -
    Filed: -

    The companion fix in the same commit takes no fork: TextEntry::notices enumerated the notice
    kinds and decision 16 made that enumeration incomplete. It is not completed by hand — a copy of
    an enum's variants is what went stale — so the field points at MetadataNotice as the list.

Correction to decision 10. Decision 10's Reverses: field names decision 6 only. It also
reverses decision 5, whose outcome — "A non-Latin-1 text keyword refuses the encode; it is
not dropped and not transliterated" — decision 10 replaced with a drop and a notice; decision 5's
own Rejected: line ("drop the annotation silently") is not what decision 10 does, since the drop
is reported, but the outcome it states is no longer the code's. Decision 5 is left as written; this
entry is the pointer. Decision 16 above in turn reverses part of decision 10.

Unresolved review notes

(none)

… pairs the spec forbids

`PngEncoder::with_metadata` / `with_metadata_from` carry a decoded file's
eXIf, iCCP, XMP, text and colour chunks into the encoder that rewrites its
pixels, so a re-encode no longer drops every one of them.

Two defects the spec settles are fixed on the way:

* sRGB beside iCCP. PNG 3rd ed. §5.6 Table 5 states the constraint on both
  rows, and §11.3.2.5 repeats it: the two should not appear together. Both
  were written whenever both were set. The encode is now refused with
  `InvalidInput`, and `with_metadata` resolves the pair by §4.3 Table 1's
  colour-chunk priority (iCCP 2 outranks sRGB 3) so a file carrying both is
  still re-encodable.

* tEXt/zTXt carried UTF-8. §11.3.3.2 interprets a tEXt text string as
  Latin-1 and §11.3.3.3 says an inflated zTXt is identical to it, while
  §11.3.3.1 restricts every keyword to Latin-1. Pushing a Rust `String`'s
  bytes stored mojibake for every code point above U+007F. Text is now
  converted once, at the setter, and a non-Latin-1 text is promoted to iTXt
  as §11.3.3.2 directs; a keyword no chunk can carry refuses the encode.

Adds `with_cicp` (§11.3.2.6), without which preservation would silently drop
the highest-precedence colour chunk a file carries.
…ncode carries

Inline in `ancillary.rs` where the assertion reads a non-pub item (`text_entry`,
`validate`, `write_text`), and in `tests/preservation.rs` for the public
`with_metadata` pair. Each names the function whose mutation it kills.

Also wires `gamut convert` to carry the input's metadata on the PNG path, with
`--strip-metadata` as the opt-out, pinned by a binary-driving test because
`gamut-cli` is outside the mutation globs and the coverage regex.
…our pair

The keyword rule shipped as "code point under 256", which is neither of the
clauses PNG states. §11.3.3.1 binds a keyword to code points 0x20-0x7E and
0xA1-0xFF, 1 to 79 bytes, with no leading, trailing or consecutive space and
expressly not U+00A0; §11.3.3.1's closing paragraph restricts a tEXt/zTXt text
string to that repertoire plus U+000A. Both are now implemented as written, so
an empty keyword, a 200-byte one, U+00A0, 0x7F and 0x9F no longer pass, and a
control character promotes to iTXt with everything else outside the repertoire
rather than being written with no defined meaning.

A null was accepted anywhere. It is the field separator, so `Auth\0or` does not
merely offend the grammar — the chunk re-parses as a *different* annotation.
§11.3.3.2 forbids it in a tEXt keyword and text string and §11.3.3.4 in an
iTXt's text and translated keyword; all four are refused, as is a language tag
outside BCP 47's subtag characters and an XMP packet that is not UTF-8. The
refusal names the annotation's index and its keyword through the owned-context
error channel, so a caller can act on it.

The sRGB-beside-iCCP refusal goes. §5.6 Table 5 and §11.3.2.5 say only "should
not" and "it is recommended", and §15 gives the BCP 14 keywords force "when,
and only when, they appear in all capitals"; §4.3 Table 1 presupposes the pair
and defines the outcome by ranking the chunks. libpng reads a file carrying
both and returns the same pixels, which `tests/oracle.rs` now pins — so the
four in-repo fixtures that had to be rewritten around the refusal are restored.

BREAKING CHANGE: a text annotation whose keyword or text breaks §11.3.3 now
fails the encode with `Error::InvalidInput` instead of being written. Keywords
that were accepted before and are not now: empty, longer than 79 bytes,
containing a null, a control character or U+00A0, and any with a leading,
trailing or consecutive space.
`gamut convert` carried a PNG input's metadata and said nothing about the
payloads it could not: a C2PA manifest store, signed over the bytes of the file
it was made for, and a cICP whose matrix coefficients PNG does not allow. Silent
loss is the defect class this path exists to remove, so both are now warned
about on stderr, which the default verbosity shows.

Also corrects the claim about the second read's cost: the metadata walk is
cheap — it skips IDAT by length and never inflates a pixel — but reading the
file from disk again is not, and that is what taking a path rather than the
already-loaded bytes costs.
The M1 row sat behind a blank line, so it rendered as a table of its own rather
than a row of the phase table. Attach it, and rewrite the section to state the
repertoire of each field as its own clause gives it, what a carry drops and
names, why both colour chunks are written, and which oracle gaps stop the claim
being differential today.
… called

Two mutants the diff gate reached and no test killed. `end_carry` could be
replaced with nothing: the idempotence test set its own annotation *before* the
carries, where the flag's state makes no difference, so it now sets one after a
carry too — the case where mistaking a direct setter for part of the carry eats
it on the next one.

`DroppedMetadata::reason` and its `Display` could return an empty string. The
lines they produce are the whole of what a user learns about metadata that did
not survive, and the test that reads them drives the `gamut` binary from
`gamut-cli`, which the mutation gate cannot see. Pin the words in gamut-png's
own suite.
…y advises

Four defects in the preservation path, all of them the same mistake in two
directions: the writer was stricter than its own reader about clauses the
specification does not bind, and looser than the file about the one field that
carries a packet's identity.

**A compressed XMP packet was rewritten uncompressed.** The packet leaves the
read side through its own field rather than as a `TextChunk`, so `parse_itxt`
bound §11.3.3.4's compression flag, language tag and translated keyword and then
discarded all three for that one keyword; the writer, having no chunk-kind to
consult, always emitted flag 0 with both strings empty. Measured on the fixture
this commit adds: a 354-byte `iTXt` came back out as 3 734 bytes, a factor of
10.6, with the tag and translated keyword gone. `XmpFraming` now travels beside
the packet on both read surfaces, and `with_xmp` — which has no source file to
take framing from — takes the one §11.3.3.1 Table 21 recommends.

**Setting the packet and then carrying one wrote two chunks.** A PNG carries one
XMP packet under one reserved keyword, so `add_xmp` replaces rather than appends,
like every other single-value payload. Appending left this crate's own
first-wins reader discarding the carried packet: a silent loss inside the feature
built to end silent loss.

**Five keyword shapes this crate reads perfectly were refused on re-encode.**
§15 gives the BCP 14 keywords force "when, and only when, they appear in all
capitals", and every statement §11.3.3.1 makes about a keyword's shape is
lowercase — the same argument that lets `sRGB` and `iCCP` be carried together.
A leading space, a trailing space, consecutive spaces, a C0/C1 control and
U+00A0 all round-trip through this crate's reader unchanged, so refusing to write
them back failed a conversion over a file whose pixels are fine, and the only
escape discarded the file's ICC profile too. They are now written verbatim and
reported. A keyword no chunk can hold — outside Latin-1, or outside the 1–79
bytes all three chunks fix — is dropped and reported, as are a language tag
outside §11.3.3.4's ASCII shape and an XMP packet that is not UTF-8. **Only a
null byte still refuses**, because it is the field separator and the chunk would
re-parse as a different annotation.

`DroppedMetadata` becomes `MetadataNotice` and `dropped_metadata` becomes
`metadata_notices`, because the channel now reports payloads that reached the
output as well as payloads that did not; `MetadataNotice::carried` separates
them, and `gamut convert` words the two cases differently.

**§11.3.3.1 and §11.3.3.2 contradict each other about a `tEXt` text string.**
§11.3.3.1's closing paragraph restricts `tEXt`/`zTXt` content to "the printable
Latin-1 character set plus U+000A LINE FEED (LF)"; §11.3.3.2, which defines
`tEXt`, says one sentence later that "The text string may contain any Latin-1
character". The more specific and more permissive clause is taken, so a
conforming annotation is no longer silently promoted to a different chunk type.
The keyword rule stays as written, being specific to keywords.

BREAKING CHANGE: `DroppedMetadata` is renamed `MetadataNotice` and gains six
variants; `PngEncoder::dropped_metadata() -> &[DroppedMetadata]` becomes
`metadata_notices() -> Vec<MetadataNotice>`. `PngMetadata` and `DecodedPng` gain
an `xmp_framing` field. An encode that carried a keyword outside §11.3.3.1's
repertoire, length or spacing rules, a non-ASCII `iTXt` language tag, or an XMP
packet that is not UTF-8 no longer fails; read `metadata_notices()` instead.

Refs #483. Refs #600.
The metadata-preservation section claimed identity was preserved under a heading
about identity, while the XMP packet — the largest payload the path carries —
lost its compression flag, language tag and translated keyword. It also listed
§11.3.3.1's keyword rules as enforced, when enforcing them refused five keyword
shapes this crate's own reader accepts.

Records instead: what the XMP packet's framing costs when it is lost (a 354-byte
`iTXt` rewritten as 3 734, measured on the fixture); the three-way split between
what refuses the encode, what is dropped and reported, and what is written
verbatim and reported, with the §15 argument for the line; and the
§11.3.3.1/§11.3.3.2 contradiction about a `tEXt` text string, quoting both halves
from the vendored text rather than picking one silently.

The "not done" list gains the seams #600 would close — the packet's parallel
fields and its position among the annotations — and the efficiency table's
metadata-hygiene axis no longer says `gamut convert` drops metadata on the PNG
path, which this work made untrue.

Refs #483. Refs #600.
`parse_itxt` binds §11.3.3.4's compression flag, language tag and translated
keyword and now hands all three to `XmpFraming`. The integration suite pins the
framed case; nothing pinned the unframed one, so a parser that reported every
packet compressed, or that kept an empty tag as `Some("")`, would have rewritten
a chunk conforming to §11.3.3.1 Table 21's recommended framing as something else
with no test failing.

Both directions are asserted here, inline, because `collect` is not public.
@justin13888 justin13888 changed the title feat(png): preserve metadata across a re-encode, and refuse the chunk pairs the spec forbids feat(png)!: preserve metadata across a re-encode, and name what could not come along Sep 10, 2026
`with_metadata` carries `cICP`, `iCCP` and `sRGB` together and justifies it with
§4.3 Table 1's Color Chunk Priority — but Table 1 ranks the chunks for a
*reader*, and which one to honour depends on whether that reader has a
colour-management module. gamut-png's own reader surfaces all of them and ranks
none, so the justification is a claim about other readers, not about this crate.
Resolving a profile against a rendering intent is `gamut-cmm`'s work (epic #323),
and this encoder deliberately does not pre-empt it.

Refs #483.
Three chunk citations on the read surface named the wrong clause, checked
against `references/png/png-3.html`: cICP is §11.3.2.6 (§11.3.2.5 is sRGB),
sRGB is §11.3.2.5 (§11.3.2.4 is sBIT), and eXIf is §11.3.4.5 (§11.3.4.4 is
sPLT). A reader following one of these lands on a different chunk's clause,
which is worse than no citation at all in a crate whose rule is that the
specification is the source of truth.

The crate also cites tRNS as §11.3.2.1 in six files, where the vendored text
numbers it §11.3.1.1 and gives §11.3.2.1 to cHRM. That is outside this change's
surface and is filed separately.

Refs #483.
A null in a text *string* refused the whole encode, on a file this crate's
own reader accepts: `split_keyword` stops at the keyword's null and hands
everything after it back as the text, nulls included, so `Comment\0val\0ue`
read as the annotation `Comment` = `val\0ue` and then failed to re-encode.
Executed on all three chunk types, including a zTXt whose inflated payload
holds one.

The mechanism the refusal was justified by is denied by the two clauses it
cited. §11.3.3.2: "The text string is not null-terminated (the length of the
chunk defines the ending)". §11.3.3.4: "The text, unlike other textual data
in this chunk, is not null-terminated; its length is derived from the chunk
length". The text is last in all three chunks, so a null in it re-frames
nothing — unlike a null in the keyword, which is the field a null separator
ends and which still refuses. This was the only shape left that made a
re-encode fail on a file gamut's own reader accepts, under the rule this
work is built on: a writer must not be stricter than its own reader.

It is not written verbatim either. libpng truncates such a text at the
null, so the chunk would hold one annotation for this crate and a shorter
one for libpng. The annotation is dropped and named instead, through the
notice channel the rest of §11.3.3's advisory rules already use:
`MetadataNotice::TextStringNull`.

Separately, `carried()` claimed `true` for an annotation dropped entirely:
`itxt_entry` pushes the language-tag notice after `text_entry` has already
cleared the emit flag for a keyword no chunk can hold, so a caller was told
its annotation came along with a caveat while zero chunks were written.
`text_notices` now derives what it reports from the entry's emit flag: an
annotation nothing was written for reports only why it was dropped.
…lour record

`with_cicp` said the fallback colour chunks "stay legal alongside it —
unlike the `sRGB`/`iCCP` pair". That is residue of a refusal this branch
removed, and it contradicts `with_srgb` two methods above and the record
entry that settles the point: §4.3 Table 1 presupposes the pair and ranks
it, so both are written and the ranking is a colour-management decision,
not this encoder's.

`TextKeywordRepertoire` said a keyword outside §11.3.3.1's repertoire is
one "another reader may reject". No real reader does — libtiff's and
libpng's read paths never validate a keyword — so the statement is both
vaguer and less true than the one the specification supports: §15.3.1
requires that "All field values in the PNG datastream obey the
relationships specified in this specification", so the datastream is
non-conforming. Precision here costs nothing.

The three direct text setters now each say where a §11.3.3 deviation is
reported and which single case still fails the encode, so a caller reaching
`with_text` first does not have to find `with_metadata` to learn it.

The XMP measurement paired the source payload's 354 bytes with a factor
computed from the 352-byte carried chunk, and attached "both strings gone"
to a figure measured with both strings present. Both figures are real; they
are now stated as the test measures them — 352 bytes with the compression
flag kept against 3 734 with it cleared — with the two lost strings named
as the separate loss they are.
…s a null drops

Two inaccuracies this round's own repair introduced.

`TextEntry::notices` enumerated what a notice can be — "a keyword no chunk
can hold, or one written verbatim that deviates from a recommendation" —
and adding `TextStringNull` made that list incomplete. It is not completed
by hand: a copy of an enum's variants written out once is exactly what goes
stale, so the field now points at `MetadataNotice` as the list and says
only what is true of every member of it, plus which flag decides whether
this entry reached the output when the variant and the entry disagree.

`TextStringNull`'s user-visible line said "text annotation", but the XMP
packet is carried as an `iTXt` too and so can reach the same notice; a
caller was told the wrong payload had been dropped. The line now names
both. The variant's own doc records what that second path means: XML 1.0
does not admit U+0000 in a document, so a packet that reaches this notice
is not merely unwritable, it is already not well-formed XML.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant