11 KiB
Capability Spec Delta: exif (exif-completion)
Package: @suaveplan/exif
Status: PROPOSED
Every requirement below carries a
**Verified by:**line at authoring time. No exceptions. (Seeopenspec/AGENTS.mdSection 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'stasks.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.vendoris"nikon"
Scenario: unknown vendor falls through to generic decoder
- WHEN
decodeMakerNotes(buffer, byteOrder, "UNKNOWN-MAKE")is called and no vendor'scanDecodereturns true - THEN the generic decoder is invoked and the returned
MakerNoteData.vendoris"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
SuaveplanErrorwith codeEXIF.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
decryptNikonMakerNoteis called against a buffer too short to carry the encrypted block - THEN it throws
SuaveplanErrorwith codeEXIF.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
decryptSonyMakerNoteis called against a buffer too short to carry the encrypted block - THEN it throws
SuaveplanErrorwith codeEXIF.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
BplistValueis adictwhose 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 isbplist02or anything other thanbplist00v0/v1 - THEN it throws
SuaveplanErrorwith codeEXIF.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
SuaveplanErrorwith codeEXIF.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
parseBplistthrowsSuaveplanErrorwith codeEXIF.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)returnstrueandprofile.tagscontains 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
SuaveplanErrorwith codeEXIF.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
SuaveplanErrorwith codeEXIF.ICC_TAG_OFFSET_OUT_OF_RANGE
Scenario: readMetadata populates metadata.icc when present
- WHEN
readMetadata(jpegWithIcc)is called with default options - THEN
metadata.iccis a populatedIccProfile
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
SuaveplanErrorwith codeEXIF.GPS_INVALID_REF
Scenario: out-of-range coordinate raises a stable error code
- WHEN
gpsCoordinatesToDecimal({ lat, latRef: "N", lon, lonRef: "E" })is called withlat > 90 - THEN it throws
SuaveplanErrorwith codeEXIF.GPS_OUT_OF_RANGE
Scenario: timestamp combines GPSDateStamp and GPSTimeStamp
- WHEN
parseGpsTimestamp("2026:05:02", [12, 34, 56])is called - THEN the returned
Daterepresents2026-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"