14 KiB
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:
MakerNoteDecoderinterface ({ 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 thedispatchandgenericshims. - ADDED:
decodeMakerNotes(buffer, byteOrder, makeTag, modelTag?)dispatcher — selects the right decoder byMaketag (case-insensitive prefix match) and falls back to the generic decoder when no vendor matches; returnsMakerNoteData. - ADDED:
decryptNikonMakerNote(buffer, serial, count)— Nikon MakerNote decipher per the legacynikon-cipher.tsimplementation (XOR + permutation table keyed by serial number and shutter count). - ADDED:
decryptSonyMakerNote(buffer)— Sony MakerNote decipher per the legacysony-cipher.tsimplementation. - 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 supportingparseIccHeader,parseIccTagTable,convertIccProfile(ports oficc/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 (IccTagSignatureenum). - 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/errorunderEXIF.*: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 aMakerNotetag and anMaketag, the result'sexif.makerNotefield carries the decodedMakerNoteDatainstead of the raw bytes; existing callers that passedoptions.includeMakerNote: falsekeep the raw-bytes behaviour for back-compat. - MODIFIED:
ReadOptions— addsincludeMakerNote?: boolean(defaulttrue),includeIcc?: boolean(defaulttrue). - MODIFIED:
ImageMetadata— adds optionalicc?: 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/dispatchmodule 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:Thumbnailsreferences 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 byMaketag; fall-through to generic decoderpackages/image/exif/src/makernotes/dispatch/dispatch.spec-conformance.test.ts—MakerNoteDecoderinterface contract pinned for every vendor;MAKERNOTE_VENDOR_UNKNOWNraised when caller demands strict modepackages/image/exif/src/makernotes/apple/apple.test.ts— known iPhone JPEG fixture decodes; bplist payload extractedpackages/image/exif/src/makernotes/canon/canon.test.ts— known 5D Mark IV fixture decodes; lens info / shooting modes extractedpackages/image/exif/src/makernotes/nikon/nikon.test.ts— known D850 fixture decodes; encrypted block decoded via injected cipherpackages/image/exif/src/makernotes/sony/sony.test.ts— known α7 III fixture decodes; encrypted block decoded via injected cipherpackages/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 eachpackages/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 entriespackages/image/exif/src/makernotes/nikon-cipher/nikon-cipher.test.ts— KAT vectors against legacy implementationpackages/image/exif/src/makernotes/sony-cipher/sony-cipher.test.ts— KAT vectors against legacy implementationpackages/image/exif/src/makernotes/apple-bplist/apple-bplist.test.ts— bplist00 v0/v1 fixture;parseBplistreturns the expected typed value treepackages/image/exif/src/makernotes/apple-bplist/apple-bplist.spec-conformance.test.ts—BPLIST_VERSION_UNSUPPORTED,BPLIST_TRAILER_INVALID,BPLIST_OBJECT_REF_OUT_OF_RANGEall raised on crafted inputspackages/image/exif/src/icc/read/read.test.ts— sRGB and Display-P3 fixtures parse;IccProfile.tagspopulatedpackages/image/exif/src/icc/write/write.test.ts— round-trip:writeIccProfile(parseIccProfile(bytes))is byte-equalpackages/image/exif/src/icc/validate/validate.test.ts— valid profiles pass; truncated profiles emitICC_HEADER_TOO_SHORT/ICC_PROFILE_TOO_SHORT; bad tag offsets emitICC_TAG_OFFSET_OUT_OF_RANGEpackages/image/exif/src/icc/convert/convert.test.ts—classifyProfile,isSRGB,isAdobeRGB,isDisplayP3against known fixturespackages/image/exif/src/icc/header/header.spec-conformance.test.ts—parseIccHeaderboundary checks;ICC_HEADER_TOO_SHORTraised on short inputpackages/image/exif/src/gps/convert/convert.test.ts— round-trip: decimal → DMS → decimal within 1e-9; altitude positive vs negative refpackages/image/exif/src/gps/convert/convert.spec-conformance.test.ts—GPS_INVALID_REFandGPS_OUT_OF_RANGEraised on crafted inputspackages/image/exif/src/gps/timestamp/timestamp.test.ts— date+time → Date round-trip; midnight/midday boundariestests/dep-graph.test.ts— subpath exports./makernotes,./icc,./gps,./bplistresolve
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 ownpackage.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.