Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,120 @@
# 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.

View File

@@ -0,0 +1,165 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: exif (exif-completion)
**Package**: `@suaveplan/exif`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions. (See `openspec/AGENTS.md` Section 13.)
>
> **Note on prior archive:** several requirements below were also listed in `openspec/archive/2026-04-19-enterprise-exif/specs/exif/spec.md` (lines 21, 24, 29-30) and ticked `[x]` in that archive's `tasks.md` (lines 37, 38, 43), but the supporting code never landed. This delta backs those claims with the actual implementation.
## ADDED Requirements
### Requirement: MakerNotes registry and dispatcher
The package SHALL expose a `MakerNoteDecoder` interface and a `decodeMakerNotes(buffer, byteOrder, makeTag, modelTag?)` dispatcher that selects the right vendor decoder by case-insensitive prefix match against the EXIF `Make` tag and falls through to a generic decoder when no vendor matches. The package SHALL ship one decoder per file under `src/makernotes/<vendor>/<vendor>.ts` for the 18 vendors covered by the legacy package: `apple`, `canon`, `casio`, `fujifilm`, `kodak`, `leica`, `minolta`, `nikon`, `olympus`, `panasonic`, `pentax`, `ricoh`, `samsung`, `sigma`, `sony`, plus the `dispatch` and `generic` shims.
#### Scenario: dispatcher selects the right vendor decoder
- **WHEN** `decodeMakerNotes(buffer, byteOrder, "NIKON CORPORATION")` is called against a Nikon MakerNote payload
- **THEN** the Nikon decoder is invoked and the returned `MakerNoteData.vendor` is `"nikon"`
#### Scenario: unknown vendor falls through to generic decoder
- **WHEN** `decodeMakerNotes(buffer, byteOrder, "UNKNOWN-MAKE")` is called and no vendor's `canDecode` returns true
- **THEN** the generic decoder is invoked and the returned `MakerNoteData.vendor` is `"generic"`
#### Scenario: strict mode raises a stable error code on unknown vendor
- **WHEN** `decodeMakerNotes(buffer, byteOrder, "UNKNOWN-MAKE", undefined, { strict: true })` is called
- **THEN** it throws `SuaveplanError` with code `EXIF.MAKERNOTE_VENDOR_UNKNOWN`
**Verified by:** `src/makernotes/dispatch/dispatch.test.ts::"selects the Nikon decoder for Make='NIKON CORPORATION'"` AND `src/makernotes/dispatch/dispatch.test.ts::"falls through to generic decoder for unknown Make"` AND `src/makernotes/dispatch/dispatch.spec-conformance.test.ts::"raises EXIF.MAKERNOTE_VENDOR_UNKNOWN in strict mode"`
---
### Requirement: Nikon cipher decoder
The package SHALL expose `decryptNikonMakerNote(buffer, serial, count)` that decrypts the encrypted block of a Nikon MakerNote per the legacy `nikon-cipher.ts` algorithm: an XOR + permutation table keyed by the camera serial number and the shutter count. Output bytes match the legacy implementation byte-for-byte against KAT vectors.
#### Scenario: KAT vector decrypts to expected plaintext
- **WHEN** `decryptNikonMakerNote(ciphertext, serial=0x12345678, count=0x9ABCDEF0)` is called for a fixture extracted from the legacy test corpus
- **THEN** the returned bytes are byte-equal to the legacy plaintext
#### Scenario: malformed cipher input raises a stable error code
- **WHEN** `decryptNikonMakerNote` is called against a buffer too short to carry the encrypted block
- **THEN** it throws `SuaveplanError` with code `EXIF.MAKERNOTE_DECRYPT_FAILED`
**Verified by:** `src/makernotes/nikon-cipher/nikon-cipher.test.ts::"matches legacy KAT vector for serial 0x12345678 / count 0x9ABCDEF0"` AND `src/makernotes/nikon-cipher/nikon-cipher.test.ts::"throws EXIF.MAKERNOTE_DECRYPT_FAILED on truncated input"`
---
### Requirement: Sony cipher decoder
The package SHALL expose `decryptSonyMakerNote(buffer)` that decrypts the encrypted block of a Sony MakerNote per the legacy `sony-cipher.ts` algorithm. Output bytes match the legacy implementation byte-for-byte against KAT vectors.
#### Scenario: KAT vector decrypts to expected plaintext
- **WHEN** `decryptSonyMakerNote(ciphertext)` is called for a fixture extracted from the legacy test corpus
- **THEN** the returned bytes are byte-equal to the legacy plaintext
#### Scenario: malformed cipher input raises a stable error code
- **WHEN** `decryptSonyMakerNote` is called against a buffer too short to carry the encrypted block
- **THEN** it throws `SuaveplanError` with code `EXIF.MAKERNOTE_DECRYPT_FAILED`
**Verified by:** `src/makernotes/sony-cipher/sony-cipher.test.ts::"matches legacy KAT vector"` AND `src/makernotes/sony-cipher/sony-cipher.test.ts::"throws EXIF.MAKERNOTE_DECRYPT_FAILED on truncated input"`
---
### Requirement: Apple binary property-list parser
The package SHALL expose `parseBplist(bytes)` that parses a binary property-list (`bplist00` v0 and v1 magic) into a typed `BplistValue` tree. `BplistValue` is a discriminated union over `dict | array | string | integer | real | boolean | date | data | uid | null` carrying its decoded value. The parser is used by the Apple MakerNote decoder for HEIC and iPhone-shot JPEG payloads.
#### Scenario: known fixture parses into the expected typed tree
- **WHEN** `parseBplist(bytes)` is called against a known iPhone JPEG MakerNote bplist fixture
- **THEN** the returned `BplistValue` is a `dict` whose keys/values match the legacy implementation's output
#### Scenario: unsupported version raises a stable error code
- **WHEN** `parseBplist(bytes)` is called against a buffer whose magic is `bplist02` or anything other than `bplist00` v0/v1
- **THEN** it throws `SuaveplanError` with code `EXIF.BPLIST_VERSION_UNSUPPORTED`
#### Scenario: truncated trailer raises a stable error code
- **WHEN** `parseBplist(bytes)` is called against a buffer whose 32-byte trailer is missing or malformed
- **THEN** it throws `SuaveplanError` with code `EXIF.BPLIST_TRAILER_INVALID`
#### Scenario: out-of-range object reference raises a stable error code
- **WHEN** the bplist trailer claims more objects than the offset table can address
- **THEN** `parseBplist` throws `SuaveplanError` with code `EXIF.BPLIST_OBJECT_REF_OUT_OF_RANGE`
**Verified by:** `src/makernotes/apple-bplist/apple-bplist.test.ts::"parses iPhone JPEG MakerNote bplist into expected typed tree"` AND `src/makernotes/apple-bplist/apple-bplist.spec-conformance.test.ts::"throws EXIF.BPLIST_VERSION_UNSUPPORTED on bplist02 magic"` AND `src/makernotes/apple-bplist/apple-bplist.spec-conformance.test.ts::"throws EXIF.BPLIST_TRAILER_INVALID on missing trailer"` AND `src/makernotes/apple-bplist/apple-bplist.spec-conformance.test.ts::"throws EXIF.BPLIST_OBJECT_REF_OUT_OF_RANGE when offset table is too short"`
---
### Requirement: ICC profile module
The package SHALL expose `parseIccProfile(bytes)`, `writeIccProfile(profile)`, `validateIccProfile(profile)`, and the convert helpers `classifyProfile`, `isSRGB`, `isAdobeRGB`, `isDisplayP3`, `profileDescription`. The module SHALL ship type definitions for `IccProfile`, `IccHeader`, `IccTagEntry`, `IccProfileClass`, `IccColorSpace`, `IccRenderingIntent`, and the four-byte tag signatures (`IccTagSignature` enum). When `readMetadata(buffer, options)` is called with `options.includeIcc !== false` (the default), and the input image carries an ICC profile (JPEG `APP2 / ICC_PROFILE\0` segment or PNG `iCCP` chunk), the result's `metadata.icc` field SHALL carry the parsed `IccProfile`.
#### Scenario: known sRGB profile parses
- **WHEN** `parseIccProfile(bytes)` is called against the IEC sRGB v4.2 profile bytes
- **THEN** `isSRGB(profile)` returns `true` and `profile.tags` contains the expected entries
#### Scenario: round-trip preserves byte-equal output
- **WHEN** `writeIccProfile(parseIccProfile(bytes))` is invoked
- **THEN** the output bytes are byte-equal to the input bytes for every fixture in the test corpus
#### Scenario: truncated header raises a stable error code
- **WHEN** `parseIccProfile(bytes)` is called against a buffer shorter than the 128-byte header
- **THEN** it throws `SuaveplanError` with code `EXIF.ICC_HEADER_TOO_SHORT`
#### Scenario: tag offset out of range raises a stable error code
- **WHEN** `parseIccProfile(bytes)` is called against a profile whose tag table claims an offset beyond the profile's declared size
- **THEN** it throws `SuaveplanError` with code `EXIF.ICC_TAG_OFFSET_OUT_OF_RANGE`
#### Scenario: `readMetadata` populates `metadata.icc` when present
- **WHEN** `readMetadata(jpegWithIcc)` is called with default options
- **THEN** `metadata.icc` is a populated `IccProfile`
**Verified by:** `src/icc/read/read.test.ts::"parses sRGB v4.2 profile and isSRGB returns true"` AND `src/icc/write/write.test.ts::"round-trips every fixture byte-equal"` AND `src/icc/header/header.spec-conformance.test.ts::"throws EXIF.ICC_HEADER_TOO_SHORT on short input"` AND `src/icc/validate/validate.test.ts::"throws EXIF.ICC_TAG_OFFSET_OUT_OF_RANGE on bad tag offset"` AND `src/core/read-metadata.test.ts::"populates metadata.icc for JPEG with APP2 ICC_PROFILE segment"`
---
### Requirement: GPS module
The package SHALL expose `gpsCoordinatesToDecimal({ lat, latRef, lon, lonRef })`, `decimalToGpsCoordinates(lat, lon)`, `parseGpsTimestamp(date, time)`, and `gpsAltitudeToMeters(value, ref)`. The conversions round-trip within 1e-9 degrees for `lat ∈ [-90, 90]` and `lon ∈ [-180, 180]`. `gpsAltitudeToMeters` honours the GPSAltitudeRef byte (0 = above sea level, 1 = below sea level → negative meters).
#### Scenario: round-trip preserves coordinates within 1e-9
- **WHEN** `gpsCoordinatesToDecimal(decimalToGpsCoordinates(lat, lon))` is invoked for any `(lat, lon)` in the valid range
- **THEN** the returned `(lat', lon')` satisfies `|lat - lat'| < 1e-9 && |lon - lon'| < 1e-9`
#### Scenario: invalid ref raises a stable error code
- **WHEN** `gpsCoordinatesToDecimal({ lat, latRef: "X", lon, lonRef: "E" })` is called (latRef is not `"N"` or `"S"`)
- **THEN** it throws `SuaveplanError` with code `EXIF.GPS_INVALID_REF`
#### Scenario: out-of-range coordinate raises a stable error code
- **WHEN** `gpsCoordinatesToDecimal({ lat, latRef: "N", lon, lonRef: "E" })` is called with `lat > 90`
- **THEN** it throws `SuaveplanError` with code `EXIF.GPS_OUT_OF_RANGE`
#### Scenario: timestamp combines GPSDateStamp and GPSTimeStamp
- **WHEN** `parseGpsTimestamp("2026:05:02", [12, 34, 56])` is called
- **THEN** the returned `Date` represents `2026-05-02T12:34:56Z`
#### Scenario: altitude with ref=1 returns negative meters
- **WHEN** `gpsAltitudeToMeters(100, 1)` is called
- **THEN** the returned value is `-100`
**Verified by:** `src/gps/convert/convert.test.ts::"round-trips decimal ↔ DMS within 1e-9 over a 1000-point random sweep"` AND `src/gps/convert/convert.spec-conformance.test.ts::"throws EXIF.GPS_INVALID_REF when latRef is not N or S"` AND `src/gps/convert/convert.spec-conformance.test.ts::"throws EXIF.GPS_OUT_OF_RANGE when lat > 90"` AND `src/gps/timestamp/timestamp.test.ts::"combines date stamp and time stamp into Date"` AND `src/gps/convert/convert.test.ts::"gpsAltitudeToMeters returns negative for ref=1"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,157 @@
# Tasks — EXIF completion (MakerNotes / ICC / GPS / Apple bplist / vendor ciphers)
> Every checkbox MUST reflect work actually done in this change. Do not retroactively tick boxes for work that was claimed but never landed. The MakerNotes / ICC / GPS gaps closed here were prematurely ticked in `openspec/archive/2026-04-19-enterprise-exif/tasks.md` lines 37, 38, 43; that's the failure mode this checklist guards against.
## 0. Pre-flight (non-negotiable)
- [ ] 0.1 Confirm clean working tree (`git status` reports nothing) and no in-flight worktrees with uncommitted work (`git worktree list` then `git status` in each)
- [ ] 0.2 If any worktrees exist under `.claude/worktrees/`, account for each one: still-active agent, work-pending-merge, or stale-and-prune. Stale worktrees MUST be removed (`git worktree remove`) before opening a new one — code that has been lost twice on main is the failure mode this guards against.
- [ ] 0.3 Confirm the package set in `MANIFEST.md` matches `find packages -maxdepth 3 -name package.json` count. Drift here means a prior change archived without regenerating the manifest.
## 1. Spec authoring
- [ ] 1.1 Read `../suaveplan/packages/universal/exif/src/{makernotes,icc,gps}` end-to-end (no dedicated `enterprise/exif.md` exists)
- [ ] 1.2 Re-read `openspec/archive/2026-04-19-enterprise-exif/tasks.md` and `specs/exif/spec.md` to confirm what was claimed but not delivered
- [ ] 1.3 Read `openspec/AGENTS.md` Sections 0a (parity), 13 (verification), 14 (archival), 14a (affected dependents)
- [ ] 1.4 Draft `proposal.md` from `openspec/templates/proposal.md`; the **Why** section explicitly cites the archive discrepancy (lines 37, 38, 43 of the archived `tasks.md`)
- [ ] 1.5 Fill in `## Source mapping` table — every requirement cites a line range OR appears in the "no source line" table with justification
- [ ] 1.6 Draft `specs/exif/spec.md` from `openspec/templates/spec-delta.md` covering all six ADDED Requirements
- [ ] 1.7 Every requirement in the spec delta carries a `**Verified by:**` line naming concrete `it(...)` titles
- [ ] 1.8 `openspec change validate exif-completion` exits with no errors
## 2. MakerNotes registry + decoders
- [ ] 2.1 Define `MakerNoteDecoder` interface in `packages/image/exif/src/makernotes/types.ts`
- [ ] 2.2 Port the dispatcher `makernotes/dispatch/dispatch.ts` (L1-L78) into `packages/image/exif/src/makernotes/dispatch/dispatch.ts`
- [ ] 2.3 Port the 18 vendor decoders (one file each):
- `apple/apple.ts` (L1-L126)
- `canon/canon.ts` (L1-L139)
- `casio/casio.ts` (L1-L6) — vendor-detection stub
- `fujifilm/fujifilm.ts` (L1-L130)
- `kodak/kodak.ts` (L1-L6) — vendor-detection stub
- `leica/leica.ts` (L1-L6) — vendor-detection stub
- `minolta/minolta.ts` (L1-L6) — vendor-detection stub
- `nikon/nikon.ts` (L1-L155)
- `olympus/olympus.ts` (L1-L129)
- `panasonic/panasonic.ts` (L1-L133)
- `pentax/pentax.ts` (L1-L140)
- `ricoh/ricoh.ts` (L1-L6) — vendor-detection stub
- `samsung/samsung.ts` (L1-L9)
- `sigma/sigma.ts` (L1-L6) — vendor-detection stub
- `sony/sony.ts` (L1-L14)
- `generic/generic.ts` (L1-L13)
- [ ] 2.4 `decodeMakerNotes(buffer, byteOrder, makeTag, modelTag?)` dispatcher — case-insensitive Make-prefix match, falls through to `generic`
- [ ] 2.5 Add `EXIF.MAKERNOTE_VENDOR_UNKNOWN`, `EXIF.MAKERNOTE_DECRYPT_FAILED`, `EXIF.MAKERNOTE_INVALID_HEADER` to `error-codes.ts`
- [ ] 2.6 Sibling `.md` for each implementation file (≥200 words, basic + advanced examples)
## 3. Vendor ciphers
- [ ] 3.1 Port `decryptNikonMakerNote` from `makernotes/nikon-cipher/nikon-cipher.ts` (L1-L84) into `packages/image/exif/src/makernotes/nikon-cipher/nikon-cipher.ts`
- [ ] 3.2 Port `decryptSonyMakerNote` from `makernotes/sony-cipher/sony-cipher.ts` (L1-L29) into `packages/image/exif/src/makernotes/sony-cipher/sony-cipher.ts`
- [ ] 3.3 Wire ciphers into the Nikon and Sony decoders; both decoders accept an injectable cipher function (default = the bundled one) for testability
- [ ] 3.4 KAT vector tests against legacy implementation
- [ ] 3.5 Sibling `.md`
## 4. Apple bplist parser
- [ ] 4.1 Port `parseBplist` from `makernotes/apple-bplist/apple-bplist.ts` (L1-L170) into `packages/image/exif/src/makernotes/apple-bplist/apple-bplist.ts`
- [ ] 4.2 Define typed value tree: `BplistValue = { type: 'dict' | 'array' | 'string' | 'integer' | 'real' | 'boolean' | 'date' | 'data' | 'uid' | 'null'; value: … }`
- [ ] 4.3 Add `EXIF.BPLIST_VERSION_UNSUPPORTED`, `EXIF.BPLIST_TRAILER_INVALID`, `EXIF.BPLIST_OBJECT_REF_OUT_OF_RANGE` to `error-codes.ts`
- [ ] 4.4 Wire `parseBplist` into the Apple decoder for HEIC + iPhone-shot JPEG
- [ ] 4.5 Sibling `.md`
## 5. ICC profile module
- [ ] 5.1 Port `icc/header/header.ts` (L1-L121), `icc/read/read.ts` (L1-L165), `icc/write/write.ts` (L1-L187), `icc/validate/validate.ts` (L1-L37), `icc/convert/convert.ts` (L1-L51), `icc/tag-table/tag-table.ts` (L1-L63) into `packages/image/exif/src/icc/{header,read,write,validate,convert,tag-table}/`
- [ ] 5.2 Define `IccProfile`, `IccHeader`, `IccTagEntry`, `IccProfileClass`, `IccColorSpace`, `IccRenderingIntent`, `IccTagSignature` types in `src/icc/types.ts`
- [ ] 5.3 Public API: `parseIccProfile`, `writeIccProfile`, `validateIccProfile`, plus convert helpers `classifyProfile`, `isSRGB`, `isAdobeRGB`, `isDisplayP3`, `profileDescription`
- [ ] 5.4 Add `EXIF.ICC_HEADER_TOO_SHORT`, `EXIF.ICC_PROFILE_TOO_SHORT`, `EXIF.ICC_TAG_OFFSET_OUT_OF_RANGE`, `EXIF.ICC_INVALID_SIGNATURE`, `EXIF.ICC_VALIDATION_FAILED` to `error-codes.ts`
- [ ] 5.5 Wire ICC into `readMetadata`: when an ICC profile segment is present in JPEG (`APP2 / ICC_PROFILE\0`) or PNG (`iCCP` chunk), populate `metadata.icc`
- [ ] 5.6 Add `includeIcc?: boolean` to `ReadOptions` (default `true`)
- [ ] 5.7 Sibling `.md` for each implementation file
## 6. GPS module
- [ ] 6.1 Port `gps/convert/convert.ts` (L1-L41) into `packages/image/exif/src/gps/convert/convert.ts`
- [ ] 6.2 Port `gps/timestamp/timestamp.ts` (L1-L38) into `packages/image/exif/src/gps/timestamp/timestamp.ts`
- [ ] 6.3 Public API: `gpsCoordinatesToDecimal`, `decimalToGpsCoordinates`, `parseGpsTimestamp`, `gpsAltitudeToMeters`
- [ ] 6.4 Add `EXIF.GPS_INVALID_REF`, `EXIF.GPS_OUT_OF_RANGE` to `error-codes.ts`
- [ ] 6.5 Sibling `.md`
## 7. Public surface
- [ ] 7.1 `package.json` `exports` map gains `./makernotes`, `./icc`, `./gps`, `./bplist` subpaths
- [ ] 7.2 Root `index.ts` re-exports `decodeMakerNotes`, `parseIccProfile`/`writeIccProfile`/`validateIccProfile`, GPS helpers, and `parseBplist`
- [ ] 7.3 `ReadOptions` gains `includeMakerNote?: boolean` and `includeIcc?: boolean` (both default `true`)
- [ ] 7.4 `ImageMetadata` gains optional `icc?: IccProfile`
- [ ] 7.5 `readMetadata` decodes MakerNote when `Make` tag present; falls through to raw bytes when `includeMakerNote: false` (back-compat)
## 8. Tests
- [ ] 8.1 `src/makernotes/dispatch/dispatch.test.ts` — vendor selection by Make tag; fall-through to generic
- [ ] 8.2 `src/makernotes/dispatch/dispatch.spec-conformance.test.ts` — `MakerNoteDecoder` interface pinned for every vendor; `MAKERNOTE_VENDOR_UNKNOWN` raised in strict mode
- [ ] 8.3 `src/makernotes/<vendor>/<vendor>.test.ts` for all 18 vendors — happy path against fixture
- [ ] 8.4 `src/makernotes/nikon-cipher/nikon-cipher.test.ts` — KAT vectors
- [ ] 8.5 `src/makernotes/sony-cipher/sony-cipher.test.ts` — KAT vectors
- [ ] 8.6 `src/makernotes/apple-bplist/apple-bplist.test.ts` — bplist00 v0/v1 fixtures
- [ ] 8.7 `src/makernotes/apple-bplist/apple-bplist.spec-conformance.test.ts` — `BPLIST_VERSION_UNSUPPORTED`, `BPLIST_TRAILER_INVALID`, `BPLIST_OBJECT_REF_OUT_OF_RANGE` paths
- [ ] 8.8 `src/icc/read/read.test.ts` — sRGB and Display-P3 fixtures
- [ ] 8.9 `src/icc/write/write.test.ts` — round-trip byte-equal
- [ ] 8.10 `src/icc/validate/validate.test.ts` — valid + 3 truncation variants
- [ ] 8.11 `src/icc/convert/convert.test.ts` — `classifyProfile`, `isSRGB`, `isAdobeRGB`, `isDisplayP3`
- [ ] 8.12 `src/icc/header/header.spec-conformance.test.ts` — `ICC_HEADER_TOO_SHORT`
- [ ] 8.13 `src/gps/convert/convert.test.ts` — round-trip decimal ↔ DMS within 1e-9; altitude positive vs negative ref
- [ ] 8.14 `src/gps/convert/convert.spec-conformance.test.ts` — `GPS_INVALID_REF`, `GPS_OUT_OF_RANGE`
- [ ] 8.15 `src/gps/timestamp/timestamp.test.ts` — date+time → Date; midnight/midday boundaries
- [ ] 8.16 `tests/dep-graph.test.ts` — subpath exports `./makernotes`, `./icc`, `./gps`, `./bplist` resolve
- [ ] 8.17 Every `it(...)` carries `{ timeout: 5000 }`
## 9. Documentation
- [ ] 9.1 Sibling `.md` for every new implementation `.ts` (≥200 words, basic + advanced examples, full API reference)
- [ ] 9.2 README links every co-located `.md` under `## Modules`
- [ ] 9.3 Update `CLAUDE.md` `exif` package listing to reflect the restored surface (MakerNotes for 18 vendors, ICC read/write/validate/convert, GPS, Apple bplist, Nikon + Sony ciphers)
## 10. Pre-archive verification (the 5-gate triple)
- [ ] 10.1 `bun run check` — Biome 0/0/0 across the monorepo
- [ ] 10.2 `bunx turbo lint typecheck test --filter=@suaveplan/exif` — green
- [ ] 10.3 `bunx turbo test:coverage --filter=@suaveplan/exif` — 100/100/100/100
- [ ] 10.4 `bun scripts/openspec-conformance.ts @suaveplan/exif` — exit 0, no `error` findings
- [ ] 10.5 `bunx turbo build --filter=@suaveplan/exif` — green
## 11. Pre-archive 7-row checklist (AGENTS.md Section 13)
- [ ] 11.1 Generics threaded; `expectTypeOf` assertions on `MakerNoteData`, `IccProfile`, `GpsCoordinate`, `BplistValue` discriminated unions
- [ ] 11.2 Zod schemas wired at every declared boundary (`MakerNoteData`, `IccProfile`, GPS inputs, `ReadOptions`)
- [ ] 11.3 Every `EXIF.*` code added in §2.5 / 3 / 4.3 / 5.4 / 6.4 thrown by at least one production path; no orphans
- [ ] 11.4 Strict-mode behaviors tested both on and off (e.g. `includeMakerNote: true` vs `false`; `includeIcc: true` vs `false`)
- [ ] 11.5 OTel histograms emitted for `exif.makernote.decode_ms`, `exif.icc.parse_ms`, `exif.gps.convert_ms`, asserted with a real in-memory `MeterProvider`
- [ ] 11.6 Lifecycle dedup tested: same image decoded twice produces identical output (deterministic; ciphers seeded from input only, no module-scope state)
- [ ] 11.7 No retroactive tick — every box above reflects work done in this change
## 12. Archive
- [ ] 12.1 Fold spec delta into `openspec/specs/exif/spec.md` (creating any missing requirements; existing requirements claimed in 2026-04-19 archive but never delivered are now backed by code)
- [ ] 12.2 Move `openspec/changes/exif-completion/` → `openspec/archive/<yyyy-mm-dd>-exif-completion/`
- [ ] 12.3 Add a note to `openspec/archive/2026-04-19-enterprise-exif/tasks.md` (or to the archive's `proposal.md`) pointing at this change as the actual delivery of §7.1, §7.2, §8.1
- [ ] 12.4 Refresh `MANIFEST.md` if the package's exports list materially changed
- [ ] 12.5 Update `STATUS.md` if the package was previously listed as Pending
## Audit Note (2026-05-02)
Audit verdict: **DEFERRED — 0/78 tasks evidenced in code**.
Evidence reviewed:
- `packages/image/exif/src/` has `binary/`, `core/`, `exif/`, `format/`, `ifd/`, `iptc/`, `jpeg/`, `xmp/`, `errors/`, `types.ts`. No `makernotes/`, no `icc/`, no `gps/`, no `bplist/`.
- The 18 vendor MakerNote decoders, the dispatcher, the Nikon + Sony ciphers, the Apple bplist parser, the ICC profile module (header/read/write/validate/convert/tag-table), and the GPS module are all absent.
- None of the new `EXIF.*` error codes (MAKERNOTE_*, BPLIST_*, ICC_*, GPS_*) appear in `errors/exif-error.ts`.
Missing pieces:
1. `makernotes/` directory with dispatcher + 18 vendor decoders + Nikon/Sony cipher pair + Apple bplist parser.
2. `icc/` module (six files: header, read, write, validate, convert, tag-table) wired into `readMetadata` for JPEG `APP2` and PNG `iCCP`.
3. `gps/` module (DMS↔decimal conversion + timestamp parsing) plus `ReadOptions` flag additions and the corresponding error codes.
Decision: leave change open. The MakerNotes block alone is 18+ vendor files with KAT cipher tests, plus the ICC and GPS modules — substantial implementation work flagged by the prior audit (entries 37/38/43 of `2026-04-19-enterprise-exif/tasks.md` were ticked but never delivered).