121 lines
14 KiB
Markdown
121 lines
14 KiB
Markdown
# EXIF completion — MakerNotes / ICC / GPS / Apple bplist / vendor ciphers
|
||
|
||
**Package**: `@suaveplan/exif`
|
||
**Status**: PROPOSED
|
||
**Type**: spec-conformance + feature
|
||
|
||
## Why
|
||
|
||
The genesis port of `@suaveplan/exif` ships only the JPEG/PNG container readers, the IFD engine, IPTC, XMP, and basic EXIF tag extraction (`packages/image/exif/src` directory listing on 2026-05-02 returned: `binary/`, `core/`, `errors/`, `exif/`, `format/`, `ifd/`, `iptc/`, `jpeg/`, `xmp/`, `types.ts`, `index.ts`). The legacy package at `../suaveplan/packages/universal/exif/src` ships an additional ~2 088 lines of MakerNotes parsers (18 vendor decoders), ICC profile read/write/validate (~624 lines), GPS conversion + timestamp helpers (~79 lines), and the Apple bplist parser (170 lines) plus Nikon (84 lines) and Sony (29 lines) cipher decoders. None of those modules are present in genesis.
|
||
|
||
**Archive discrepancy (must be flagged in the proposal):** `openspec/archive/2026-04-19-enterprise-exif/tasks.md` line 37 (`- [x] 7.1 GPS: convert + timestamp`), line 38 (`- [x] 7.2 ICC: read / write / validate / convert (classifyProfile, isSRGB, isAdobeRGB, isDisplayP3, profileDescription)`), and line 43 (`- [x] 8.1 Dispatch + Apple bplist + Apple + Canon + Nikon (+ cipher) + Sony (+ cipher)`) are all ticked. The archived spec at `openspec/archive/2026-04-19-enterprise-exif/specs/exif/spec.md` lines 21, 24, 29-30 lists corresponding requirements as accepted. **The code never landed.** This change closes that gap honestly and also extends the surface beyond Apple/Canon/Nikon/Sony to cover the full 18-vendor matrix shipped by the legacy package (Olympus, Fujifilm, Panasonic, Pentax, Casio, Kodak, Leica, Minolta, Ricoh, Samsung, Sigma, plus the dispatch shim and the generic decoder).
|
||
|
||
Genesis policy is "no half-baked or mediocre libraries" (CLAUDE.md §Workflow rule 3) and "Preserve the full public surface … never trim on 'probably unused' grounds" (CLAUDE.md §Workflow rule 5). Retroactive ticks on completed work are the failure mode AGENTS.md §13 explicitly guards against; this change exists because that gate failed.
|
||
|
||
## What changes
|
||
|
||
### `@suaveplan/exif`
|
||
|
||
- ADDED: `MakerNoteDecoder` interface (`{ vendor: string; canDecode(make: string, model?: string): boolean; decode(buffer: Uint8Array, byteOrder: ByteOrder, ifd: IfdEntry[]): MakerNoteData }`) — pluggable contract for vendor decoders.
|
||
- ADDED: 18 vendor decoders, one per file under `src/makernotes/<vendor>/<vendor>.ts`: `apple`, `canon`, `casio`, `fujifilm`, `kodak`, `leica`, `minolta`, `nikon`, `olympus`, `panasonic`, `pentax`, `ricoh`, `samsung`, `sigma`, `sony`, plus the `dispatch` and `generic` shims.
|
||
- ADDED: `decodeMakerNotes(buffer, byteOrder, makeTag, modelTag?)` dispatcher — selects the right decoder by `Make` tag (case-insensitive prefix match) and falls back to the generic decoder when no vendor matches; returns `MakerNoteData`.
|
||
- ADDED: `decryptNikonMakerNote(buffer, serial, count)` — Nikon MakerNote decipher per the legacy `nikon-cipher.ts` implementation (XOR + permutation table keyed by serial number and shutter count).
|
||
- ADDED: `decryptSonyMakerNote(buffer)` — Sony MakerNote decipher per the legacy `sony-cipher.ts` implementation.
|
||
- ADDED: `parseBplist(bytes)` — Apple binary property-list parser; returns a typed value tree (`{ type: 'dict' | 'array' | 'string' | 'integer' | 'real' | 'boolean' | 'date' | 'data' | 'uid' | 'null'; value: … }`); used by the Apple MakerNote decoder for HEIC and iPhone-shot JPEG.
|
||
- ADDED: ICC profile module under `src/icc/`: `parseIccProfile(bytes)`, `writeIccProfile(profile)`, `validateIccProfile(profile)`, plus the supporting `parseIccHeader`, `parseIccTagTable`, `convertIccProfile` (ports of `icc/header/header.ts`, `icc/read/read.ts`, `icc/write/write.ts`, `icc/validate/validate.ts`, `icc/convert/convert.ts`, `icc/tag-table/tag-table.ts`).
|
||
- ADDED: ICC type definitions: `IccProfile`, `IccHeader`, `IccTagEntry`, `IccProfileClass`, `IccColorSpace`, `IccRenderingIntent`, plus the four-byte tag signatures (`IccTagSignature` enum).
|
||
- ADDED: GPS module under `src/gps/`: `gpsCoordinatesToDecimal({ lat, latRef, lon, lonRef })`, `decimalToGpsCoordinates(lat, lon)`, `parseGpsTimestamp(date, time)`, `gpsAltitudeToMeters(value, ref)`.
|
||
- ADDED: error codes registered on `@suaveplan/error` under `EXIF.*`: `MAKERNOTE_VENDOR_UNKNOWN`, `MAKERNOTE_DECRYPT_FAILED`, `MAKERNOTE_INVALID_HEADER`, `BPLIST_VERSION_UNSUPPORTED`, `BPLIST_TRAILER_INVALID`, `BPLIST_OBJECT_REF_OUT_OF_RANGE`, `ICC_HEADER_TOO_SHORT`, `ICC_PROFILE_TOO_SHORT`, `ICC_TAG_OFFSET_OUT_OF_RANGE`, `ICC_INVALID_SIGNATURE`, `ICC_VALIDATION_FAILED`, `GPS_INVALID_REF`, `GPS_OUT_OF_RANGE`.
|
||
- ADDED: subpath exports `./makernotes`, `./icc`, `./gps`, `./bplist`.
|
||
- MODIFIED: `readMetadata(buffer, options)` — when the parsed EXIF IFD contains a `MakerNote` tag and an `Make` tag, the result's `exif.makerNote` field carries the decoded `MakerNoteData` instead of the raw bytes; existing callers that passed `options.includeMakerNote: false` keep the raw-bytes behaviour for back-compat.
|
||
- MODIFIED: `ReadOptions` — adds `includeMakerNote?: boolean` (default `true`), `includeIcc?: boolean` (default `true`).
|
||
- MODIFIED: `ImageMetadata` — adds optional `icc?: IccProfile`; populated when an ICC profile segment is present.
|
||
|
||
## Out of scope
|
||
|
||
- **Writing MakerNotes** — read-only is sufficient for the v1 surface. Round-tripping vendor MakerNotes requires verified per-vendor schemas and is its own change. Legacy upstream is also read-only.
|
||
- **RAW container support (CR2, NEF, ARW, RAF, RW2, ORF, DNG bodies beyond TIFF)** — only JPEG and PNG are in scope. RAW handling lives behind the legacy `formats/raw/dispatch` module which is its own large surface; deferred to a separate change.
|
||
- **HEIC container parsing** — the genesis package does not yet expose ISOBMFF/HEIC. Apple bplist support lands here because it is a MakerNote dependency (used inside JPEG MakerNote payloads on iPhone-shot JPEGs), but full HEIC container support is a separate change.
|
||
- **Adobe XMP-side ICC** — XMP can carry ICC by reference; this change ships the binary ICC parser only. Cross-linking XMP `xmp:Thumbnails` references to ICC profiles is its own change.
|
||
- **Differential fuzz against `exifr`** — the legacy archive's §9 differential harness is out of scope; the spec-conformance tests pin the implementation against KAT vectors instead.
|
||
|
||
## Source mapping
|
||
|
||
This change derives from `../suaveplan/packages/universal/exif/src` directly. There is no dedicated `enterprise/exif.md` audit doc upstream.
|
||
|
||
| Requirement | Source file | Source lines | Notes |
|
||
|---|---|---|---|
|
||
| `MakerNoteDecoder` interface and `decodeMakerNotes` dispatcher | `makernotes/dispatch/dispatch.ts` | L1-L78 | Verbatim |
|
||
| Apple MakerNote decoder | `makernotes/apple/apple.ts` | L1-L126 | Verbatim |
|
||
| Canon MakerNote decoder | `makernotes/canon/canon.ts` | L1-L139 | Verbatim |
|
||
| Casio MakerNote decoder | `makernotes/casio/casio.ts` | L1-L6 | Stub-with-vendor-detection (per legacy) |
|
||
| Fujifilm MakerNote decoder | `makernotes/fujifilm/fujifilm.ts` | L1-L130 | Verbatim |
|
||
| Kodak MakerNote decoder | `makernotes/kodak/kodak.ts` | L1-L6 | Stub-with-vendor-detection |
|
||
| Leica MakerNote decoder | `makernotes/leica/leica.ts` | L1-L6 | Stub-with-vendor-detection |
|
||
| Minolta MakerNote decoder | `makernotes/minolta/minolta.ts` | L1-L6 | Stub-with-vendor-detection |
|
||
| Nikon MakerNote decoder | `makernotes/nikon/nikon.ts` | L1-L155 | Verbatim |
|
||
| Olympus MakerNote decoder | `makernotes/olympus/olympus.ts` | L1-L129 | Verbatim |
|
||
| Panasonic MakerNote decoder | `makernotes/panasonic/panasonic.ts` | L1-L133 | Verbatim |
|
||
| Pentax MakerNote decoder | `makernotes/pentax/pentax.ts` | L1-L140 | Verbatim |
|
||
| Ricoh MakerNote decoder | `makernotes/ricoh/ricoh.ts` | L1-L6 | Stub-with-vendor-detection |
|
||
| Samsung MakerNote decoder | `makernotes/samsung/samsung.ts` | L1-L9 | Verbatim |
|
||
| Sigma MakerNote decoder | `makernotes/sigma/sigma.ts` | L1-L6 | Stub-with-vendor-detection |
|
||
| Sony MakerNote decoder | `makernotes/sony/sony.ts` | L1-L14 | Verbatim |
|
||
| Generic MakerNote decoder | `makernotes/generic/generic.ts` | L1-L13 | Verbatim |
|
||
| `decryptNikonMakerNote` | `makernotes/nikon-cipher/nikon-cipher.ts` | L1-L84 | Verbatim |
|
||
| `decryptSonyMakerNote` | `makernotes/sony-cipher/sony-cipher.ts` | L1-L29 | Verbatim |
|
||
| `parseBplist` | `makernotes/apple-bplist/apple-bplist.ts` | L1-L170 | Verbatim |
|
||
| `parseIccHeader` | `icc/header/header.ts` | L1-L121 | Verbatim |
|
||
| `parseIccProfile` | `icc/read/read.ts` | L1-L165 | Verbatim |
|
||
| `writeIccProfile` | `icc/write/write.ts` | L1-L187 | Verbatim |
|
||
| `validateIccProfile` | `icc/validate/validate.ts` | L1-L37 | Verbatim |
|
||
| `convertIccProfile` (`classifyProfile`, `isSRGB`, `isAdobeRGB`, `isDisplayP3`, `profileDescription`) | `icc/convert/convert.ts` | L1-L51 | Verbatim |
|
||
| ICC tag-table signatures | `icc/tag-table/tag-table.ts` | L1-L63 | Verbatim |
|
||
| `gpsCoordinatesToDecimal`, `decimalToGpsCoordinates`, `gpsAltitudeToMeters` | `gps/convert/convert.ts` | L1-L41 | Verbatim |
|
||
| `parseGpsTimestamp` | `gps/timestamp/timestamp.ts` | L1-L38 | Verbatim |
|
||
|
||
### Requirements with no source line
|
||
|
||
| Requirement | Justification |
|
||
|---|---|
|
||
| `EXIF.*` error codes registered on `@suaveplan/error` | Genesis-wide policy that every thrown error route through `@suaveplan/error` (CLAUDE.md §Workflow rule 2). Upstream throws plain `Error`. |
|
||
| `./makernotes`, `./icc`, `./gps`, `./bplist` subpath exports | Genesis-wide subpath-per-feature rule (AGENTS.md §6). Upstream uses a single barrel; tree-shaking parity demands subpath splits. |
|
||
| `includeMakerNote` / `includeIcc` `ReadOptions` flags | Back-compat: legacy upstream behaviour is "always decode if present"; genesis ships flags so callers who don't need MakerNotes can skip the decode work. Default-on preserves legacy behaviour. |
|
||
|
||
## Verification approach
|
||
|
||
Each spec delta requirement carries a `**Verified by:**` line pointing at concrete `it(...)` titles. The test files that will exist after this change ships:
|
||
|
||
- `packages/image/exif/src/makernotes/dispatch/dispatch.test.ts` — vendor selection by `Make` tag; fall-through to generic decoder
|
||
- `packages/image/exif/src/makernotes/dispatch/dispatch.spec-conformance.test.ts` — `MakerNoteDecoder` interface contract pinned for every vendor; `MAKERNOTE_VENDOR_UNKNOWN` raised when caller demands strict mode
|
||
- `packages/image/exif/src/makernotes/apple/apple.test.ts` — known iPhone JPEG fixture decodes; bplist payload extracted
|
||
- `packages/image/exif/src/makernotes/canon/canon.test.ts` — known 5D Mark IV fixture decodes; lens info / shooting modes extracted
|
||
- `packages/image/exif/src/makernotes/nikon/nikon.test.ts` — known D850 fixture decodes; encrypted block decoded via injected cipher
|
||
- `packages/image/exif/src/makernotes/sony/sony.test.ts` — known α7 III fixture decodes; encrypted block decoded via injected cipher
|
||
- `packages/image/exif/src/makernotes/olympus/olympus.test.ts`, `fujifilm/fujifilm.test.ts`, `panasonic/panasonic.test.ts`, `pentax/pentax.test.ts`, `samsung/samsung.test.ts` — one happy-path fixture each
|
||
- `packages/image/exif/src/makernotes/casio/casio.test.ts`, `kodak/kodak.test.ts`, `leica/leica.test.ts`, `minolta/minolta.test.ts`, `ricoh/ricoh.test.ts`, `sigma/sigma.test.ts` — vendor-detection-only assertions (matches legacy stub coverage)
|
||
- `packages/image/exif/src/makernotes/generic/generic.test.ts` — fall-through path emits raw IFD entries
|
||
- `packages/image/exif/src/makernotes/nikon-cipher/nikon-cipher.test.ts` — KAT vectors against legacy implementation
|
||
- `packages/image/exif/src/makernotes/sony-cipher/sony-cipher.test.ts` — KAT vectors against legacy implementation
|
||
- `packages/image/exif/src/makernotes/apple-bplist/apple-bplist.test.ts` — bplist00 v0/v1 fixture; `parseBplist` returns the expected typed value tree
|
||
- `packages/image/exif/src/makernotes/apple-bplist/apple-bplist.spec-conformance.test.ts` — `BPLIST_VERSION_UNSUPPORTED`, `BPLIST_TRAILER_INVALID`, `BPLIST_OBJECT_REF_OUT_OF_RANGE` all raised on crafted inputs
|
||
- `packages/image/exif/src/icc/read/read.test.ts` — sRGB and Display-P3 fixtures parse; `IccProfile.tags` populated
|
||
- `packages/image/exif/src/icc/write/write.test.ts` — round-trip: `writeIccProfile(parseIccProfile(bytes))` is byte-equal
|
||
- `packages/image/exif/src/icc/validate/validate.test.ts` — valid profiles pass; truncated profiles emit `ICC_HEADER_TOO_SHORT` / `ICC_PROFILE_TOO_SHORT`; bad tag offsets emit `ICC_TAG_OFFSET_OUT_OF_RANGE`
|
||
- `packages/image/exif/src/icc/convert/convert.test.ts` — `classifyProfile`, `isSRGB`, `isAdobeRGB`, `isDisplayP3` against known fixtures
|
||
- `packages/image/exif/src/icc/header/header.spec-conformance.test.ts` — `parseIccHeader` boundary checks; `ICC_HEADER_TOO_SHORT` raised on short input
|
||
- `packages/image/exif/src/gps/convert/convert.test.ts` — round-trip: decimal → DMS → decimal within 1e-9; altitude positive vs negative ref
|
||
- `packages/image/exif/src/gps/convert/convert.spec-conformance.test.ts` — `GPS_INVALID_REF` and `GPS_OUT_OF_RANGE` raised on crafted inputs
|
||
- `packages/image/exif/src/gps/timestamp/timestamp.test.ts` — date+time → Date round-trip; midnight/midday boundaries
|
||
- `tests/dep-graph.test.ts` — subpath exports `./makernotes`, `./icc`, `./gps`, `./bplist` resolve
|
||
|
||
## Impact
|
||
|
||
### Affected dependents
|
||
|
||
Run `bunx turbo ls --filter=...@suaveplan/exif` to enumerate. Result of `grep -rl "@suaveplan/exif" /home/ubuntu/web/genesis/packages/ --include="package.json"` on 2026-05-02 returned only the package itself (`packages/image/exif/package.json`). No in-repo dependents.
|
||
|
||
- No dependents (verified by `grep -rl "@suaveplan/exif" /home/ubuntu/web/genesis/packages/ --include="package.json"` returning only the package's own `package.json`).
|
||
|
||
This change is **additive on its public surface** — every existing export is preserved and the `readMetadata` return shape gains optional fields (`exif.makerNote` decoded payload, `icc` block) without breaking existing callers. Callers that currently treat `exif.makerNote` as raw bytes need to either set `options.includeMakerNote: false` (gets old behaviour) or update their type expectations to the new `MakerNoteData` discriminated union — flagged as a behaviour change in the migration table for any future consumer that lands.
|