166 lines
11 KiB
Markdown
166 lines
11 KiB
Markdown
<!-- 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 -->
|