Files
suaveplan/openspec/changes/exif-completion/specs/exif/spec.md
2026-05-27 23:29:53 +01:00

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. (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"