Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
64 changes: 63 additions & 1 deletion crates/gamut-exif/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,65 @@ let out = edited.to_bytes(); // Exif\0\0 + TIFF, ready to re-embe
```

For a bare TIFF stream (PNG `eXIf` / WebP `EXIF`) or a byte-order override, use [`ExifWriter`];
[`ExifReader`] carries the read-side options (`require_marker`, `strict`).
[`ExifReader`] carries the read-side options (`require_marker`, `strict`) and two further entry
points:

- **`parse_from`** reads through [`gamut_ifd::ReadAt`] instead of a slice, so the EXIF of a
300 MB raw file costs a few hundred bytes of I/O rather than the whole file. `parse` is the
`&[u8]` case of it — one parse engine, two entry points. It is deliberately synchronous: an
async caller drives the source itself, which keeps a runtime dependency out of the crate.
- **`parse_with_report`** (and its `parse_from_with_report` twin) returns a `ReadReport` alongside
the `Exif`, naming each region the lenient reader discarded — a malformed Exif/GPS/Interop
sub-IFD, an unusable thumbnail range, or a top-level directory past the 1st IFD — with the tag
that addressed it, the offset it carried, and a typed reason. `parse` stays silent, as before.
The report is complete over those regions but is **not** a byte-completeness verdict: an empty
report does not mean the parse lost nothing (see the deferred items below). A `strict` report is
not always empty either: strictness rejects *malformed* regions, and a trailing directory is
well-formed and merely unrepresentable, so it is reported in both modes.

```rust
# use gamut_exif::ExifReader;
# fn demo(bytes: &[u8]) -> Result<(), gamut_exif::ExifError> {
let (exif, report) = ExifReader::new().parse_with_report(bytes)?;
for dropped in report.dropped() {
// e.g. "dropped GPS (tag 0x8825) at offset 65535: addresses bytes outside the EXIF blob"
eprintln!("{dropped}");
}
# let _ = exif;
# Ok(())
# }
```

Enable the optional `geocoordinates` feature (also included by `full`) to convert a complete
[`GpsInfo`] with `TryFrom` into `geocoordinates::Wgs84` or `geocoordinates::Coordinate`. The latter
preserves EXIF sea-level altitude as an orthometric height; the 2D `Wgs84` newtype intentionally
drops altitude. Malformed references, rationals, DMS components, and out-of-range positions return
the typed [`GpsConversionError`].

## Compatibility

**One read verdict changed after 1.0.0.** A 1st IFD carrying `JPEGInterchangeFormat` with no
`JPEGInterchangeFormatLength` used to parse as a thumbnail that simply had no bytes; it is a
**loss** — named in the report as `DropReason::ThumbnailLengthMissing`, and **rejected by
`strict`** with `ExifError::BadThumbnail`. A caller running `strict` over blobs 1.0.0 accepted
should know the verdict moved.

The rule is about **readability**: an offset with nothing to size it addresses bytes that cannot be
read, which is what `strict` is for. It is not about a support level, and the reader does not
consult `Compression` at all.

**Conformance** is the separate question of whether the move is a *fix* or a *redefinition*, and it
is a fix, so no major version is forced. Exif 3.0 §4.6.9.2 Table 21 states each 1st IFD tag's
support level per thumbnail-format column: three uncompressed ones distinguished by photometric
interpretation and planar configuration (Chunky, Planar, YCC), plus **Compressed**. That axis is not
the two-valued `Compression` tag. The table gives `JPEGInterchangeFormat` and
`JPEGInterchangeFormatLength` the *same* level in each column: "not allowed to record" under the
three uncompressed columns, mandatory under Compressed. An offset with no length is therefore
non-conformant under every column, and no conformant 1st IFD changes verdict. That grounding is what
this repository ships — the table is vendored under `references/exif/`; the before/after comparison
measured behind it is recorded in the pull request that introduced the report, not committed here as
a harness.

## Scope

v1 covers the **standard CIPA DC-008 tag dictionary** ([`ExifTag`]), full read/write round-trips
Expand All @@ -67,6 +118,17 @@ designed to be added without breaking the 1.0 API — the catalogue and vendor e
- **exiftool-parity tag breadth** beyond the standard dictionary (unknown tags still round-trip
losslessly via the raw `Ifd`).
- **Uncompressed strip-based thumbnails** are read but not re-embedded (JPEG thumbnails are).
- **Per-tag error recovery inside one directory.** A single unparseable entry fails its whole
directory in `gamut-ifd`, so the report's granularity is the sub-IFD, not the individual tag
(issue #521).
- **A signal for a shadowed duplicate tag.** Two entries for one tag decode to the last, and the
earlier one is discarded a layer below this crate. `gamut-exif` could *detect* the loss (compare
`RawIfd::entries` against the decoded `Ifd::fields()`), but not describe it without re-decoding
the shadowed entry, so the signal belongs where the discarding happens — a layer three crates
share (issue #528).
- **A byte-completeness verdict** over the whole blob (which source bytes no parsed structure
claims). `gamut-ifd`'s audit engine has the machinery; `ReadReport` today reports only what was
dropped, not what was never reached (issue #521).

## Status

Expand Down
27 changes: 25 additions & 2 deletions crates/gamut-exif/STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ fixtures** (`tests/fixtures/`, regenerate with `GAMUT_REGEN_GOLDEN=1`).
| P6 | §4.6 | **Keystone** — writer round-trip (endianness/pointers/thumbnail preserved) | ✅ |
| P7 | §4.6 | MakerNote: opaque passthrough + vendor detection (no per-vendor decode) | ✅ |
| P8 | — | exiv2 differential gate + golden fixtures | ✅ |
| P9 | §4.6 | `ReadAt` streaming entry point + lenient-drop report (`ReadReport`, scoped to the regions `DroppedRegion` names) | ✅ |

## Intentionally deferred (additive under the `#[non_exhaustive]` surface)

Expand All @@ -35,5 +36,27 @@ fixtures** (`tests/fixtures/`, regenerate with `GAMUT_REGEN_GOLDEN=1`).
round-trip losslessly because the raw `gamut_ifd::Ifd` is retained.
- **Uncompressed strip-based thumbnails** are read (as their directory) but not re-embedded; JPEG
thumbnails round-trip fully.
- **A reader coverage/validation report** — `gamut-ifd` has the machinery; exposing a toggle on
`ExifReader` is a non-breaking future addition.
- **Per-tag error recovery inside a directory.** `ExifReader::parse_with_report` names the
sub-IFDs, thumbnail ranges and trailing top-level directories the lenient reader discards (issue
#419), but the granularity is the directory: a single unparseable entry fails its whole IFD in
`gamut-ifd`, which is the layer that would have to recover per entry. nom-exif's
`entry.into_result()` is finer-grained here.
- **A signal for a shadowed duplicate tag.** `gamut_ifd::IfdReader::decode_ifd` builds a directory
with `Ifd::set`, which is last-wins, so two entries for one tag decode to the second and the
first is discarded. `ReadReport::is_empty()` is therefore a verdict over the regions it covers,
**not** "this parse lost nothing"; both the report's module docs and the README say so.

The deferral is a layering decision, **not** an inability to observe: `RawIfd::entries` is public
and in on-disk order and `follow` already holds the `RawIfd`, so `raw.entries.len() !=
ifd.fields().len()` would detect a shadowed tag here in three lines. What `gamut-exif` cannot do
is say *what* was lost without re-decoding the shadowed entry — and `gamut-tiff` and `gamut-dng`
need the same signal, so it belongs in the shared layer (issue #528). Issue #528's own body
states the weaker, incorrect reason; read it with this correction.
- **A byte-completeness verdict.** `ReadReport` says what was *dropped*, not which source bytes no
parsed structure claims. `gamut-ifd`'s audit engine (`Tracked`, `SegmentMap`, `read_audited`) is
the machinery for it and is already used by `gamut-dng` and `gamut-tiff`; wiring it behind a
toggle on `ExifReader` stays a non-breaking future addition.
- **An async entry point.** Declined rather than deferred: `parse_from` is synchronous over
`gamut_ifd::ReadAt`, and an async caller drives that source itself. A `tokio` feature would put a
runtime dependency in a crate that has none and constrain the public shape against the
C-portability convention, for a capability the caller can supply.
27 changes: 27 additions & 0 deletions crates/gamut-exif/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,30 @@
//! [`Exif::parse`] reads a blob and [`Exif::to_bytes`] re-serialises it (preserving the byte order);
//! read tags with the typed accessors or the [`ExifTag`] catalogue.
//!
//! Two further read entry points sit on [`ExifReader`]:
//! [`parse_from`](ExifReader::parse_from) reads through [`gamut_ifd::ReadAt`] rather than a slice,
//! so EXIF can be pulled out of a large raw file without loading it, and
//! [`parse_with_report`](ExifReader::parse_with_report) returns a [`ReadReport`] naming the
//! sub-IFDs, thumbnail ranges and trailing directories the lenient reader discarded — see
//! [`report`] for what that covers and what it deliberately does not. `parse` is the `&[u8]` case
//! of `parse_from` and stays silent.
//!
//! `parse` keeps its signature, its accept/reject verdict and its re-serialised bytes, with two
//! narrow exceptions, both measured over a 3 144-case truncation-and-corruption sweep against the
//! previous release. An error message's offset is now a position in the buffer the caller handed
//! in, so for a marked blob it is six bytes larger than before — the `Exif\0\0` marker — while a
//! [`Dropped::offset`] stays relative to the TIFF stream; and a 1st IFD carrying
//! `JPEGInterchangeFormat` with no `JPEGInterchangeFormatLength` is now named in the report
//! instead of vanishing, and rejected in [`strict`](ExifReader::strict) mode as the unreadable
//! range it is — an offset with nothing to size it addresses bytes that cannot be read. That rule
//! is structural, not a support level: Exif 3.0 §4.6.9.2 Table 21 states the pair's level only *per
//! thumbnail-format column*, an axis that is not the two-valued `Compression` tag — which this
//! reader does not consult, though [`Thumbnail::compression`](thumbnail::Thumbnail::compression)
//! exposes it to callers (issue #574). It refuses no conformant input: the table gives both tags
//! the same level in all four columns — `M` under **Compressed**, `N` (not allowed to record) under
//! the three uncompressed ones — so an offset with no length is non-conformant under every one of
//! them.
//!
//! ```
//! use gamut_exif::{ByteOrder, Exif, ExifTag, Value};
//!
Expand All @@ -35,6 +59,8 @@ pub mod exif;
pub mod gps;
pub mod maker_note;
pub mod reader;
pub mod report;
mod stream;
pub mod tag;
pub mod thumbnail;
pub mod value;
Expand All @@ -50,6 +76,7 @@ pub use gps::GpsConversionError;
pub use gps::{GpsAltitude, GpsCoordinate, GpsInfo, GpsReference};
pub use maker_note::{MakerNote, MakerNoteVendor};
pub use reader::ExifReader;
pub use report::{DropReason, Dropped, DroppedRegion, ReadReport};
pub use tag::{ExifTag, IfdKind};
pub use thumbnail::Thumbnail;
pub use value::{Rational, SRational, as_text};
Expand Down
Loading
Loading