From 1416a335cef989552c69ada0de234e3590cc3b48 Mon Sep 17 00:00:00 2001 From: Puneet Rai Date: Sat, 7 Feb 2026 23:10:30 +0530 Subject: [PATCH 1/2] feat(makernote): add MakerNote parser interface and registry Implements MNI-1 (Issue #10) - the foundation for manufacturer-specific MakerNote parsing in the TIFF/EXIF metadata pipeline. ## New Package: internal/parser/tiff/makernote/ - Handler interface: Manufacturer(), Detect(), Parse(), TagName() - Config struct: IFDOffset, OffsetBase, ByteOrder, HasNextIFD, Variant - Registry pattern with ordered detection for manufacturer routing - Detection functions for all major manufacturers: - Nikon Type 3: 'Nikon' + 0x02 (embedded TIFF header) - Nikon Type 1: 'Nikon' + 0x01 - Sony: 'SONY DSC' or 'SONY CAM' (12-byte header) - Fujifilm: 'FUJIFILM' (8-byte header + offset) - Canon: No header (IFD validation fallback) ## TIFF Parser Integration - Added handleMakerNote() to ifd.go for TagMakerNote (0x927C) - Integrated makernote.Registry into Parser struct - Preserves backward compatibility: returns raw MakerNote when no handler matches ## Offset Handling - OffsetAbsolute: Canon, Nikon T1/T2, Sony (relative to TIFF base) - OffsetRelativeToMakerNote: Fujifilm, Nikon T3 Closes #10 Co-Authored-By: Claude Opus 4.5 --- internal/parser/tiff/ifd.go | 61 ++- internal/parser/tiff/ifd_test.go | 7 +- internal/parser/tiff/makernote/config.go | 42 ++ internal/parser/tiff/makernote/makernote.go | 235 +++++++++++ .../parser/tiff/makernote/makernote_test.go | 376 ++++++++++++++++++ internal/parser/tiff/tiff.go | 24 +- 6 files changed, 731 insertions(+), 14 deletions(-) create mode 100644 internal/parser/tiff/makernote/config.go create mode 100644 internal/parser/tiff/makernote/makernote.go create mode 100644 internal/parser/tiff/makernote/makernote_test.go diff --git a/internal/parser/tiff/ifd.go b/internal/parser/tiff/ifd.go index a986191..375988d 100644 --- a/internal/parser/tiff/ifd.go +++ b/internal/parser/tiff/ifd.go @@ -3,6 +3,7 @@ package tiff import ( "bytes" "fmt" + "io" "strings" imxbin "github.com/gomantics/imx/internal/binary" @@ -11,7 +12,7 @@ import ( ) // parseIFD parses an IFD at the given offset -func (p *Parser) parseIFD(r *imxbin.Reader, offset int64, dirName string, iccDirs, iptcDirs, xmpDirs *[]parser.Directory, sharedParseErr *parser.ParseError) (*parser.Directory, *parser.ParseError, []SubIFD, uint16) { +func (p *Parser) parseIFD(r *imxbin.Reader, fileReader io.ReaderAt, offset int64, dirName string, iccDirs, iptcDirs, xmpDirs, makernoteDirs *[]parser.Directory, sharedParseErr *parser.ParseError) (*parser.Directory, *parser.ParseError, []SubIFD, uint16) { var parseErr *parser.ParseError if sharedParseErr != nil { // Use shared error accumulator for multi-IFD parsing @@ -60,6 +61,8 @@ func (p *Parser) parseIFD(r *imxbin.Reader, offset int64, dirName string, iccDir p.handleIPTC(r, entry, parseErr, iptcDirs) case TagXMP: p.handleXMP(r, entry, parseErr, xmpDirs) + case TagMakerNote: + p.handleMakerNote(r, fileReader, entry, &dir.Tags, parseErr, makernoteDirs) default: // Regular tag tag, err := p.parseTag(r, entry, dirName) @@ -516,6 +519,62 @@ func (p *Parser) handleXMP(r *imxbin.Reader, entry *IFDEntry, parseErr *parser.P } } +// handleMakerNote handles MakerNote tag (tag 0x927C) +// MakerNote contains manufacturer-specific metadata in various formats. +// When a manufacturer handler is registered and parses successfully, the +// parsed tags are returned in a separate directory. +// When no handler matches, the raw MakerNote data is returned as a tag. +func (p *Parser) handleMakerNote(r *imxbin.Reader, fileReader io.ReaderAt, entry *IFDEntry, dirTags *[]parser.Tag, parseErr *parser.ParseError, makernoteDirs *[]parser.Directory) { + // Read MakerNote data + makerNoteOffset := int64(entry.ValueOffset) + data, err := r.ReadBytes(makerNoteOffset, int(entry.Count)) + if err != nil { + parseErr.Add(fmt.Errorf("failed to read MakerNote data at offset %d: %w", makerNoteOffset, err)) + return + } + + // If no registry or no handler matches, return raw MakerNote as a tag + if p.makernote == nil { + *dirTags = append(*dirTags, parser.Tag{ + ID: parser.TagID("ExifIFD:0x927C"), + Name: "MakerNote", + Value: data, + DataType: "UNDEFINED", + }) + return + } + + handler, cfg := p.makernote.Detect(data) + if handler == nil { + // Unknown manufacturer - return raw data as tag + *dirTags = append(*dirTags, parser.Tag{ + ID: parser.TagID("ExifIFD:0x927C"), + Name: "MakerNote", + Value: data, + DataType: "UNDEFINED", + }) + return + } + + // Detect manufacturer and parse + // exifBase is 0 for standard TIFF files (TIFF header at file start) + // TODO: For JPEG files, this would need to be the APP1 EXIF header offset + exifBase := int64(0) + + tags, mnErr := handler.Parse(fileReader, makerNoteOffset, exifBase, cfg) + if mnErr != nil { + parseErr.Merge(mnErr) + } + + // Create directory for MakerNote tags if any were parsed + if len(tags) > 0 { + *makernoteDirs = append(*makernoteDirs, parser.Directory{ + Name: handler.Manufacturer(), + Tags: tags, + }) + } +} + // handleSubIFDs handles SubIFDs tag (tag 0x014A) // SubIFDs contain an array of offsets to sub-IFDs for preview/RAW image data func (p *Parser) handleSubIFDs(r *imxbin.Reader, entry *IFDEntry, subIFDs *[]SubIFD, parseErr *parser.ParseError) { diff --git a/internal/parser/tiff/ifd_test.go b/internal/parser/tiff/ifd_test.go index 555b04a..26dafb1 100644 --- a/internal/parser/tiff/ifd_test.go +++ b/internal/parser/tiff/ifd_test.go @@ -163,8 +163,9 @@ func TestParser_parseIFD(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - reader := imxbin.NewReader(bytes.NewReader(tt.data), order) - var iccDirs, iptcDirs, xmpDirs []parser.Directory + fileReader := bytes.NewReader(tt.data) + reader := imxbin.NewReader(fileReader, order) + var iccDirs, iptcDirs, xmpDirs, makernoteDirs []parser.Directory // Test both with shared error and without var parseErr *parser.ParseError @@ -174,7 +175,7 @@ func TestParser_parseIFD(t *testing.T) { } else { parseErr = parser.NewParseError() } - dir, _, subIFDs, numEntries := p.parseIFD(reader, 0, "IFD0", &iccDirs, &iptcDirs, &xmpDirs, parseErr) + dir, _, subIFDs, numEntries := p.parseIFD(reader, fileReader, 0, "IFD0", &iccDirs, &iptcDirs, &xmpDirs, &makernoteDirs, parseErr) if (dir != nil) != tt.wantDir { t.Errorf("dir = %v, wantDir %v", dir != nil, tt.wantDir) diff --git a/internal/parser/tiff/makernote/config.go b/internal/parser/tiff/makernote/config.go new file mode 100644 index 0000000..d968d51 --- /dev/null +++ b/internal/parser/tiff/makernote/config.go @@ -0,0 +1,42 @@ +package makernote + +import "encoding/binary" + +// OffsetBase determines how tag value offsets are calculated. +type OffsetBase int + +const ( + // OffsetAbsolute means offsets are relative to EXIF TIFF header. + // Used by: Canon, Nikon Type 1/2, Sony + OffsetAbsolute OffsetBase = iota + + // OffsetRelativeToMakerNote means offsets are relative to MakerNote start. + // Used by: Fujifilm, Nikon Type 3 + OffsetRelativeToMakerNote +) + +// Config holds manufacturer-specific parsing configuration. +// Returned by Handler.Detect() to configure parsing behavior. +type Config struct { + // IFDOffset is where the IFD starts within the MakerNote data. + // For Canon: 0 (no header) + // For Nikon Type 1: 8 (after 8-byte header) + // For Nikon Type 3: 18 (after 10-byte header + 8-byte TIFF header) + // For Sony: 12 (after 12-byte header) + // For Fujifilm: 12 (after 8-byte header + 4-byte offset) + IFDOffset int64 + + // OffsetBase determines how tag value offsets are calculated. + OffsetBase OffsetBase + + // ByteOrder for parsing. If nil, inherits from parent EXIF. + // Nikon Type 3 and Fujifilm have embedded byte order. + ByteOrder binary.ByteOrder + + // HasNextIFD indicates if next-IFD pointer should be followed. + // Canon CR2 files have non-zero next-IFD that should be ignored. + HasNextIFD bool + + // Variant identifies the specific format variant (e.g., "Type1", "Type3"). + Variant string +} diff --git a/internal/parser/tiff/makernote/makernote.go b/internal/parser/tiff/makernote/makernote.go new file mode 100644 index 0000000..f2ff484 --- /dev/null +++ b/internal/parser/tiff/makernote/makernote.go @@ -0,0 +1,235 @@ +// Package makernote provides MakerNote parsing for camera manufacturer-specific metadata. +// +// MakerNote is an EXIF tag (0x927C) containing manufacturer-specific metadata. +// Each manufacturer uses a different format, requiring specialized parsers. +// +// Supported manufacturers: +// - Canon: No header, IFD at offset 0, absolute offsets +// - Nikon: Type 1 (8-byte header), Type 3 (embedded TIFF) +// - Sony: 12-byte header, absolute offsets, little-endian +// - Fujifilm: 8-byte header + offset, relative offsets, little-endian +package makernote + +import ( + "bytes" + "encoding/binary" + "io" + + "github.com/gomantics/imx/internal/parser" +) + +// Handler defines the interface for manufacturer-specific MakerNote parsers. +type Handler interface { + // Manufacturer returns the manufacturer name (e.g., "Canon", "Nikon"). + Manufacturer() string + + // Detect checks if this handler can parse the given MakerNote data. + // Returns true and a Config if the data matches this manufacturer's format. + Detect(data []byte) (bool, *Config) + + // Parse extracts metadata from the MakerNote. + // r: reader for the entire file (needed for offset-based reads) + // makerNoteOffset: absolute offset of MakerNote in file + // exifBase: absolute offset of EXIF TIFF header (for absolute offset calculation) + // cfg: parsing configuration from Detect() + Parse(r io.ReaderAt, makerNoteOffset, exifBase int64, cfg *Config) ([]parser.Tag, *parser.ParseError) + + // TagName returns the human-readable name for a tag ID. + TagName(tagID uint16) string +} + +// Registry manages manufacturer handlers and routes MakerNote data to the appropriate parser. +type Registry struct { + handlers []Handler +} + +// NewRegistry creates a new Registry with no registered handlers. +func NewRegistry() *Registry { + return &Registry{ + handlers: make([]Handler, 0), + } +} + +// Register adds a handler to the registry. +// Handlers are checked in registration order during detection. +// Register handlers in priority order: most specific first. +// +// Recommended order: +// 1. Nikon Type 3 ('Nikon' + 0x02) +// 2. Nikon Type 1 ('Nikon' + 0x01) +// 3. Sony ('SONY DSC' or 'SONY CAM') +// 4. Fujifilm ('FUJIFILM') +// 5. Canon (no header - validated by IFD structure, must be last) +func (r *Registry) Register(h Handler) { + r.handlers = append(r.handlers, h) +} + +// Detect finds the appropriate handler for the given MakerNote data. +// Returns the handler and its configuration, or nil if no handler matches. +func (r *Registry) Detect(data []byte) (Handler, *Config) { + for _, h := range r.handlers { + if ok, cfg := h.Detect(data); ok { + return h, cfg + } + } + return nil, nil +} + +// Parse parses MakerNote data using the appropriate manufacturer handler. +// Returns nil tags (not error) for unknown manufacturers. +func (r *Registry) Parse(reader io.ReaderAt, makerNoteOffset, exifBase int64, data []byte) ([]parser.Tag, *parser.ParseError) { + handler, cfg := r.Detect(data) + if handler == nil { + // Unknown manufacturer - return nil without error + return nil, nil + } + return handler.Parse(reader, makerNoteOffset, exifBase, cfg) +} + +// Header detection constants +var ( + // Nikon headers + nikonHeader = []byte("Nikon") + nikonType3Magic = byte(0x02) + nikonType1Magic = byte(0x01) + + // Sony headers + sonyDSCHeader = []byte("SONY DSC ") + sonyCAMHeader = []byte("SONY CAM ") + + // Fujifilm header + fujifilmHeader = []byte("FUJIFILM") +) + +// DetectNikonType3 checks for Nikon Type 3 format. +// Header: 'Nikon' + 0x00 + 0x02 + 0x00 + 0x00 + embedded TIFF header +func DetectNikonType3(data []byte) (bool, *Config) { + if len(data) < 18 { + return false, nil + } + + // Check 'Nikon' + 0x00 + 0x02 + if !bytes.HasPrefix(data, nikonHeader) { + return false, nil + } + if data[5] != 0x00 || data[6] != nikonType3Magic { + return false, nil + } + + // Read embedded TIFF header byte order at offset 10 + var order binary.ByteOrder + if data[10] == 'I' && data[11] == 'I' { + order = binary.LittleEndian + } else if data[10] == 'M' && data[11] == 'M' { + order = binary.BigEndian + } else { + return false, nil + } + + return true, &Config{ + IFDOffset: 18, // 10-byte header + 8-byte TIFF header + OffsetBase: OffsetRelativeToMakerNote, + ByteOrder: order, + HasNextIFD: false, + Variant: "Type3", + } +} + +// DetectNikonType1 checks for Nikon Type 1 format. +// Header: 'Nikon' + 0x00 + 0x01 + 0x00 +func DetectNikonType1(data []byte) (bool, *Config) { + if len(data) < 8 { + return false, nil + } + + // Check 'Nikon' + 0x00 + 0x01 + if !bytes.HasPrefix(data, nikonHeader) { + return false, nil + } + if data[5] != 0x00 || data[6] != nikonType1Magic { + return false, nil + } + + return true, &Config{ + IFDOffset: 8, + OffsetBase: OffsetAbsolute, + ByteOrder: nil, // Inherit from parent + HasNextIFD: false, + Variant: "Type1", + } +} + +// DetectSony checks for Sony format. +// Header: 'SONY DSC ' or 'SONY CAM ' (12 bytes) +func DetectSony(data []byte) (bool, *Config) { + if len(data) < 12 { + return false, nil + } + + if !bytes.HasPrefix(data, sonyDSCHeader) && !bytes.HasPrefix(data, sonyCAMHeader) { + return false, nil + } + + return true, &Config{ + IFDOffset: 12, + OffsetBase: OffsetAbsolute, + ByteOrder: binary.LittleEndian, + HasNextIFD: false, + Variant: "Standard", + } +} + +// DetectFujifilm checks for Fujifilm format. +// Header: 'FUJIFILM' (8 bytes) + 4-byte IFD offset (little-endian) +func DetectFujifilm(data []byte) (bool, *Config) { + if len(data) < 12 { + return false, nil + } + + if !bytes.HasPrefix(data, fujifilmHeader) { + return false, nil + } + + // Read IFD offset at bytes 8-11 (little-endian) + ifdOffset := int64(binary.LittleEndian.Uint32(data[8:12])) + + return true, &Config{ + IFDOffset: ifdOffset, + OffsetBase: OffsetRelativeToMakerNote, + ByteOrder: binary.LittleEndian, + HasNextIFD: false, + Variant: "Standard", + } +} + +// DetectCanon checks for Canon format. +// Canon has no header - the MakerNote starts directly with an IFD. +// Detection: first 2 bytes form a valid entry count (1-100). +// This should be called LAST as a fallback. +func DetectCanon(data []byte) (bool, *Config) { + if len(data) < 14 { // Minimum: 2-byte count + one 12-byte entry + return false, nil + } + + // First 2 bytes are entry count (little-endian assumed, validated later) + entryCount := binary.LittleEndian.Uint16(data[0:2]) + + // Sanity check: reasonable entry count (1-100) + if entryCount < 1 || entryCount > 100 { + return false, nil + } + + // Additional validation: check if data is large enough for entries + requiredSize := 2 + int(entryCount)*12 + if len(data) < requiredSize { + return false, nil + } + + return true, &Config{ + IFDOffset: 0, + OffsetBase: OffsetAbsolute, + ByteOrder: nil, // Inherit from parent + HasNextIFD: false, + Variant: "Standard", + } +} diff --git a/internal/parser/tiff/makernote/makernote_test.go b/internal/parser/tiff/makernote/makernote_test.go new file mode 100644 index 0000000..e5813e7 --- /dev/null +++ b/internal/parser/tiff/makernote/makernote_test.go @@ -0,0 +1,376 @@ +package makernote + +import ( + "encoding/binary" + "testing" +) + +func TestDetectNikonType3(t *testing.T) { + tests := []struct { + name string + data []byte + wantMatch bool + wantOrder binary.ByteOrder + }{ + { + name: "valid Nikon Type 3 little-endian", + data: append( + []byte("Nikon\x00\x02\x00\x00\x00"), // 10-byte header + []byte("II\x2a\x00\x08\x00\x00\x00")..., // TIFF header LE + ), + wantMatch: true, + wantOrder: binary.LittleEndian, + }, + { + name: "valid Nikon Type 3 big-endian", + data: append( + []byte("Nikon\x00\x02\x00\x00\x00"), + []byte("MM\x00\x2a\x00\x00\x00\x08")..., + ), + wantMatch: true, + wantOrder: binary.BigEndian, + }, + { + name: "Nikon Type 1 header - should not match", + data: []byte("Nikon\x00\x01\x00"), + wantMatch: false, + }, + { + name: "too short", + data: []byte("Nikon\x00\x02"), + wantMatch: false, + }, + { + name: "invalid byte order marker", + data: append([]byte("Nikon\x00\x02\x00\x00\x00"), []byte("XX\x2a\x00\x08\x00\x00\x00")...), + wantMatch: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + match, cfg := DetectNikonType3(tt.data) + if match != tt.wantMatch { + t.Errorf("DetectNikonType3() match = %v, want %v", match, tt.wantMatch) + } + if match { + if cfg.ByteOrder != tt.wantOrder { + t.Errorf("DetectNikonType3() order = %v, want %v", cfg.ByteOrder, tt.wantOrder) + } + if cfg.IFDOffset != 18 { + t.Errorf("DetectNikonType3() IFDOffset = %d, want 18", cfg.IFDOffset) + } + if cfg.OffsetBase != OffsetRelativeToMakerNote { + t.Errorf("DetectNikonType3() OffsetBase = %v, want OffsetRelativeToMakerNote", cfg.OffsetBase) + } + if cfg.Variant != "Type3" { + t.Errorf("DetectNikonType3() Variant = %s, want Type3", cfg.Variant) + } + } + }) + } +} + +func TestDetectNikonType1(t *testing.T) { + tests := []struct { + name string + data []byte + wantMatch bool + }{ + { + name: "valid Nikon Type 1", + data: []byte("Nikon\x00\x01\x00"), + wantMatch: true, + }, + { + name: "Nikon Type 3 header - should not match", + data: []byte("Nikon\x00\x02\x00\x00\x00IIII"), + wantMatch: false, + }, + { + name: "too short", + data: []byte("Nikon\x00"), + wantMatch: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + match, cfg := DetectNikonType1(tt.data) + if match != tt.wantMatch { + t.Errorf("DetectNikonType1() match = %v, want %v", match, tt.wantMatch) + } + if match { + if cfg.IFDOffset != 8 { + t.Errorf("DetectNikonType1() IFDOffset = %d, want 8", cfg.IFDOffset) + } + if cfg.OffsetBase != OffsetAbsolute { + t.Errorf("DetectNikonType1() OffsetBase = %v, want OffsetAbsolute", cfg.OffsetBase) + } + if cfg.ByteOrder != nil { + t.Errorf("DetectNikonType1() ByteOrder = %v, want nil (inherit)", cfg.ByteOrder) + } + } + }) + } +} + +func TestDetectSony(t *testing.T) { + tests := []struct { + name string + data []byte + wantMatch bool + }{ + { + name: "valid SONY DSC", + data: []byte("SONY DSC \x00\x00\x00"), + wantMatch: true, + }, + { + name: "valid SONY CAM", + data: []byte("SONY CAM \x00\x00\x00"), + wantMatch: true, + }, + { + name: "too short", + data: []byte("SONY DSC"), + wantMatch: false, + }, + { + name: "wrong header", + data: []byte("SONY ABC \x00\x00\x00"), + wantMatch: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + match, cfg := DetectSony(tt.data) + if match != tt.wantMatch { + t.Errorf("DetectSony() match = %v, want %v", match, tt.wantMatch) + } + if match { + if cfg.IFDOffset != 12 { + t.Errorf("DetectSony() IFDOffset = %d, want 12", cfg.IFDOffset) + } + if cfg.OffsetBase != OffsetAbsolute { + t.Errorf("DetectSony() OffsetBase = %v, want OffsetAbsolute", cfg.OffsetBase) + } + if cfg.ByteOrder != binary.LittleEndian { + t.Errorf("DetectSony() ByteOrder = %v, want LittleEndian", cfg.ByteOrder) + } + } + }) + } +} + +func TestDetectFujifilm(t *testing.T) { + tests := []struct { + name string + data []byte + wantMatch bool + wantIFDOffset int64 + }{ + { + name: "valid Fujifilm with offset 12", + data: []byte("FUJIFILM\x0c\x00\x00\x00"), + wantMatch: true, + wantIFDOffset: 12, + }, + { + name: "valid Fujifilm with offset 20", + data: []byte("FUJIFILM\x14\x00\x00\x00"), + wantMatch: true, + wantIFDOffset: 20, + }, + { + name: "too short", + data: []byte("FUJIFILM"), + wantMatch: false, + }, + { + name: "wrong header", + data: []byte("FUJIXXXX\x0c\x00\x00\x00"), + wantMatch: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + match, cfg := DetectFujifilm(tt.data) + if match != tt.wantMatch { + t.Errorf("DetectFujifilm() match = %v, want %v", match, tt.wantMatch) + } + if match { + if cfg.IFDOffset != tt.wantIFDOffset { + t.Errorf("DetectFujifilm() IFDOffset = %d, want %d", cfg.IFDOffset, tt.wantIFDOffset) + } + if cfg.OffsetBase != OffsetRelativeToMakerNote { + t.Errorf("DetectFujifilm() OffsetBase = %v, want OffsetRelativeToMakerNote", cfg.OffsetBase) + } + if cfg.ByteOrder != binary.LittleEndian { + t.Errorf("DetectFujifilm() ByteOrder = %v, want LittleEndian", cfg.ByteOrder) + } + } + }) + } +} + +func TestDetectCanon(t *testing.T) { + // Canon IFD: 2-byte entry count + 12-byte entries + // Create a minimal valid IFD with 2 entries + makeCanonIFD := func(entryCount uint16) []byte { + data := make([]byte, 2+int(entryCount)*12) + binary.LittleEndian.PutUint16(data[0:2], entryCount) + return data + } + + tests := []struct { + name string + data []byte + wantMatch bool + }{ + { + name: "valid Canon with 2 entries", + data: makeCanonIFD(2), + wantMatch: true, + }, + { + name: "valid Canon with 50 entries", + data: makeCanonIFD(50), + wantMatch: true, + }, + { + name: "valid Canon with 1 entry", + data: makeCanonIFD(1), + wantMatch: true, + }, + { + name: "invalid - 0 entries", + data: makeCanonIFD(0), + wantMatch: false, + }, + { + name: "invalid - too many entries (101)", + data: []byte{101, 0}, // Entry count 101 + wantMatch: false, + }, + { + name: "too short", + data: []byte{2, 0}, // 2 entries but no entry data + wantMatch: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + match, cfg := DetectCanon(tt.data) + if match != tt.wantMatch { + t.Errorf("DetectCanon() match = %v, want %v", match, tt.wantMatch) + } + if match { + if cfg.IFDOffset != 0 { + t.Errorf("DetectCanon() IFDOffset = %d, want 0", cfg.IFDOffset) + } + if cfg.OffsetBase != OffsetAbsolute { + t.Errorf("DetectCanon() OffsetBase = %v, want OffsetAbsolute", cfg.OffsetBase) + } + if cfg.ByteOrder != nil { + t.Errorf("DetectCanon() ByteOrder = %v, want nil (inherit)", cfg.ByteOrder) + } + } + }) + } +} + +func TestDetectionPriority(t *testing.T) { + // Test that detection order is respected by the registry + registry := NewRegistry() + + // Create mock handlers for testing priority + type mockHandler struct { + name string + detect func([]byte) (bool, *Config) + } + + // The registry should be populated in the correct order + // This test verifies the detection functions don't have false positives + + t.Run("Nikon Type 3 not matched by Type 1", func(t *testing.T) { + data := append( + []byte("Nikon\x00\x02\x00\x00\x00"), + []byte("II\x2a\x00\x08\x00\x00\x00")..., + ) + match, _ := DetectNikonType1(data) + if match { + t.Error("Nikon Type 3 data should not match Type 1 detection") + } + }) + + t.Run("Sony not matched by Fujifilm", func(t *testing.T) { + data := []byte("SONY DSC \x00\x00\x00") + match, _ := DetectFujifilm(data) + if match { + t.Error("Sony data should not match Fujifilm detection") + } + }) + + t.Run("Fujifilm not matched by Canon", func(t *testing.T) { + // FUJIFILM header would have entry count 0x5546 ('FU') which is > 100 + data := []byte("FUJIFILM\x0c\x00\x00\x00") + match, _ := DetectCanon(data) + if match { + t.Error("Fujifilm data should not match Canon detection") + } + }) + + _ = registry // Registry tested implicitly via detection functions +} + +func TestRegistryDetect(t *testing.T) { + // Create a simple test handler + registry := NewRegistry() + + // Test empty registry returns nil + handler, cfg := registry.Detect([]byte("test data")) + if handler != nil || cfg != nil { + t.Error("Empty registry should return nil") + } +} + +func TestConfigFields(t *testing.T) { + // Verify Config struct has all required fields + cfg := &Config{ + IFDOffset: 12, + OffsetBase: OffsetRelativeToMakerNote, + ByteOrder: binary.LittleEndian, + HasNextIFD: true, + Variant: "Type3", + } + + if cfg.IFDOffset != 12 { + t.Errorf("IFDOffset = %d, want 12", cfg.IFDOffset) + } + if cfg.OffsetBase != OffsetRelativeToMakerNote { + t.Errorf("OffsetBase = %v, want OffsetRelativeToMakerNote", cfg.OffsetBase) + } + if cfg.ByteOrder != binary.LittleEndian { + t.Errorf("ByteOrder = %v, want LittleEndian", cfg.ByteOrder) + } + if !cfg.HasNextIFD { + t.Error("HasNextIFD = false, want true") + } + if cfg.Variant != "Type3" { + t.Errorf("Variant = %s, want Type3", cfg.Variant) + } +} + +func TestOffsetBaseConstants(t *testing.T) { + // Verify OffsetBase constants are defined correctly + if OffsetAbsolute != 0 { + t.Errorf("OffsetAbsolute = %d, want 0", OffsetAbsolute) + } + if OffsetRelativeToMakerNote != 1 { + t.Errorf("OffsetRelativeToMakerNote = %d, want 1", OffsetRelativeToMakerNote) + } +} diff --git a/internal/parser/tiff/tiff.go b/internal/parser/tiff/tiff.go index 88a6d80..58fc7d9 100644 --- a/internal/parser/tiff/tiff.go +++ b/internal/parser/tiff/tiff.go @@ -9,6 +9,7 @@ import ( "github.com/gomantics/imx/internal/parser" "github.com/gomantics/imx/internal/parser/icc" "github.com/gomantics/imx/internal/parser/iptc" + "github.com/gomantics/imx/internal/parser/tiff/makernote" "github.com/gomantics/imx/internal/parser/xmp" ) @@ -32,17 +33,19 @@ import ( // - MEF (Mamiya Raw Format) - Mamiya raw files // - MOS (Leaf Raw) - Leaf raw files type Parser struct { - icc *icc.Parser - iptc *iptc.Parser - xmp *xmp.Parser + icc *icc.Parser + iptc *iptc.Parser + xmp *xmp.Parser + makernote *makernote.Registry } // New creates a new TIFF parser func New() *Parser { return &Parser{ - icc: icc.New(), - iptc: iptc.New(), - xmp: xmp.New(), + icc: icc.New(), + iptc: iptc.New(), + xmp: xmp.New(), + makernote: makernote.NewRegistry(), } } @@ -71,7 +74,7 @@ func (p *Parser) Parse(r io.ReaderAt) ([]parser.Directory, *parser.ParseError) { var dirs []parser.Directory // Embedded metadata directories (collected locally for thread safety) - var iccDirs, iptcDirs, xmpDirs []parser.Directory + var iccDirs, iptcDirs, xmpDirs, makernoteDirs []parser.Directory // Read header to determine byte order headerBuf := make([]byte, tiffHeaderSize) @@ -106,7 +109,7 @@ func (p *Parser) Parse(r io.ReaderAt) ([]parser.Directory, *parser.ParseError) { reader := imxbin.NewReader(r, order) // Parse IFD0 - ifd0Dir, ifd0Err, subIFDs, numEntries := p.parseIFD(reader, ifd0Offset, "IFD0", &iccDirs, &iptcDirs, &xmpDirs, parseErr) + ifd0Dir, ifd0Err, subIFDs, numEntries := p.parseIFD(reader, r, ifd0Offset, "IFD0", &iccDirs, &iptcDirs, &xmpDirs, &makernoteDirs, parseErr) if ifd0Err != nil { parseErr.Merge(ifd0Err) } @@ -116,7 +119,7 @@ func (p *Parser) Parse(r io.ReaderAt) ([]parser.Directory, *parser.ParseError) { // Parse sub-IFDs (EXIF, GPS, Interoperability, SubIFDs for RAW previews) for _, sub := range subIFDs { - subDir, subErr, _, _ := p.parseIFD(reader, sub.Offset, sub.Name, &iccDirs, &iptcDirs, &xmpDirs, parseErr) + subDir, subErr, _, _ := p.parseIFD(reader, r, sub.Offset, sub.Name, &iccDirs, &iptcDirs, &xmpDirs, &makernoteDirs, parseErr) if subErr != nil { parseErr.Merge(subErr) } @@ -130,7 +133,7 @@ func (p *Parser) Parse(r io.ReaderAt) ([]parser.Directory, *parser.ParseError) { nextIFDOffsetPos := ifd0Offset + ifdEntryCountSize + int64(numEntries)*ifdEntrySize nextIFDOffset, err := reader.ReadUint32(nextIFDOffsetPos) if err == nil && nextIFDOffset != 0 { - ifd1Dir, ifd1Err, _, _ := p.parseIFD(reader, int64(nextIFDOffset), "IFD1", &iccDirs, &iptcDirs, &xmpDirs, parseErr) + ifd1Dir, ifd1Err, _, _ := p.parseIFD(reader, r, int64(nextIFDOffset), "IFD1", &iccDirs, &iptcDirs, &xmpDirs, &makernoteDirs, parseErr) if ifd1Err != nil { parseErr.Merge(ifd1Err) } @@ -143,6 +146,7 @@ func (p *Parser) Parse(r io.ReaderAt) ([]parser.Directory, *parser.ParseError) { dirs = append(dirs, iccDirs...) dirs = append(dirs, iptcDirs...) dirs = append(dirs, xmpDirs...) + dirs = append(dirs, makernoteDirs...) return dirs, parseErr.OrNil() } From 35cb7c6ec431363adcf63196ac2f63e4363be857 Mon Sep 17 00:00:00 2001 From: Puneet Rai Date: Sun, 8 Feb 2026 00:26:27 +0530 Subject: [PATCH 2/2] feat(makernote): add Canon MakerNote parser Implement Canon-specific MakerNote parsing with support for 35+ tag types including CameraSettings, ImageType, SerialNumber, LensModel, and ModelID. Canon MakerNote format: - No header - IFD starts immediately at offset 0 - Byte order inherited from parent EXIF - Offsets are absolute (relative to EXIF TIFF header) Changes: - Add canon/canon.go with Parse() implementation for IFD parsing - Add canon/tags.go with tag constants and name mappings - Add canon/canon_test.go with comprehensive unit tests - Register Canon handler in tiff.go (last priority, fallback detection) - Update integration test to verify 28 Canon tags from CR2 files Co-Authored-By: Claude Opus 4.5 --- api_integration_test.go | 36 ++ internal/parser/tiff/ifd.go | 33 +- internal/parser/tiff/makernote/canon/canon.go | 370 ++++++++++++++++ .../parser/tiff/makernote/canon/canon_test.go | 416 ++++++++++++++++++ internal/parser/tiff/makernote/canon/tags.go | 88 ++++ internal/parser/tiff/tiff.go | 8 +- 6 files changed, 932 insertions(+), 19 deletions(-) create mode 100644 internal/parser/tiff/makernote/canon/canon.go create mode 100644 internal/parser/tiff/makernote/canon/canon_test.go create mode 100644 internal/parser/tiff/makernote/canon/tags.go diff --git a/api_integration_test.go b/api_integration_test.go index e9c5834..b0721b2 100644 --- a/api_integration_test.go +++ b/api_integration_test.go @@ -305,6 +305,42 @@ func TestIntegration_CR2(t *testing.T) { {Name: "JPEGInterchangeFormatLength", Value: uint32(13120)}, }, }, + { + Name: "Canon", + ExactTagCount: 28, // Canon EOS-1Ds Mark II MakerNote + Tags: []imxtest.TagExpectation{ + // Core Canon tags + {Name: "CameraSettings1"}, + {Name: "FocalLength"}, + {Name: "FlashInfo"}, + {Name: "CameraSettings2"}, + {Name: "ImageType"}, + {Name: "FirmwareVersion"}, + {Name: "OwnerName"}, + {Name: "SerialNumber"}, + {Name: "CameraInfo"}, + {Name: "CustomFunctions"}, + {Name: "ModelID"}, + {Name: "AFInfo"}, + {Name: "ColorInfo"}, + {Name: "VRDOffset"}, + {Name: "SensorInfo"}, + {Name: "ColorData"}, + {Name: "CRWParam"}, + {Name: "ColorInfo2"}, + // Unknown/undocumented tags (identified by hex code) + {Name: "0x0013"}, + {Name: "0x0015"}, + {Name: "0x0019"}, + {Name: "0x0083"}, + {Name: "0x0091"}, + {Name: "0x0092"}, + {Name: "0x0093"}, + {Name: "0x0094"}, + {Name: "0x00AA"}, + {Name: "0x4004"}, + }, + }, }) if result.Failed() { for _, err := range result.Errors { diff --git a/internal/parser/tiff/ifd.go b/internal/parser/tiff/ifd.go index 375988d..d71ed53 100644 --- a/internal/parser/tiff/ifd.go +++ b/internal/parser/tiff/ifd.go @@ -521,9 +521,9 @@ func (p *Parser) handleXMP(r *imxbin.Reader, entry *IFDEntry, parseErr *parser.P // handleMakerNote handles MakerNote tag (tag 0x927C) // MakerNote contains manufacturer-specific metadata in various formats. -// When a manufacturer handler is registered and parses successfully, the -// parsed tags are returned in a separate directory. -// When no handler matches, the raw MakerNote data is returned as a tag. +// The raw MakerNote data is always returned as a tag in ExifIFD. +// When a manufacturer handler is registered and parses successfully, +// the parsed tags are also returned in a separate manufacturer directory. func (p *Parser) handleMakerNote(r *imxbin.Reader, fileReader io.ReaderAt, entry *IFDEntry, dirTags *[]parser.Tag, parseErr *parser.ParseError, makernoteDirs *[]parser.Directory) { // Read MakerNote data makerNoteOffset := int64(entry.ValueOffset) @@ -533,30 +533,27 @@ func (p *Parser) handleMakerNote(r *imxbin.Reader, fileReader io.ReaderAt, entry return } - // If no registry or no handler matches, return raw MakerNote as a tag + // Always add raw MakerNote tag for backward compatibility + *dirTags = append(*dirTags, parser.Tag{ + ID: parser.TagID("ExifIFD:0x927C"), + Name: "MakerNote", + Value: data, + DataType: "UNDEFINED", + }) + + // If no registry, we're done if p.makernote == nil { - *dirTags = append(*dirTags, parser.Tag{ - ID: parser.TagID("ExifIFD:0x927C"), - Name: "MakerNote", - Value: data, - DataType: "UNDEFINED", - }) return } + // Try to detect and parse manufacturer-specific format handler, cfg := p.makernote.Detect(data) if handler == nil { - // Unknown manufacturer - return raw data as tag - *dirTags = append(*dirTags, parser.Tag{ - ID: parser.TagID("ExifIFD:0x927C"), - Name: "MakerNote", - Value: data, - DataType: "UNDEFINED", - }) + // Unknown manufacturer - raw tag already added above return } - // Detect manufacturer and parse + // Parse manufacturer-specific tags // exifBase is 0 for standard TIFF files (TIFF header at file start) // TODO: For JPEG files, this would need to be the APP1 EXIF header offset exifBase := int64(0) diff --git a/internal/parser/tiff/makernote/canon/canon.go b/internal/parser/tiff/makernote/canon/canon.go new file mode 100644 index 0000000..7132d46 --- /dev/null +++ b/internal/parser/tiff/makernote/canon/canon.go @@ -0,0 +1,370 @@ +// Package canon implements Canon MakerNote parsing. +// +// Canon MakerNote format: +// - No header - IFD starts immediately at offset 0 +// - Byte order inherited from parent EXIF +// - Offsets are absolute (relative to EXIF TIFF header) +// - CR2 files may have non-zero next-IFD pointer (ignored) +// +// Key tags include CameraSettings arrays, SerialNumber, LensModel, and ModelID. +package canon + +import ( + "encoding/binary" + "fmt" + "io" + + "github.com/gomantics/imx/internal/parser" + "github.com/gomantics/imx/internal/parser/tiff/makernote" +) + +// Handler implements makernote.Handler for Canon cameras. +type Handler struct{} + +// New creates a new Canon MakerNote handler. +func New() *Handler { + return &Handler{} +} + +// Manufacturer returns the manufacturer name. +func (h *Handler) Manufacturer() string { + return "Canon" +} + +// Detect checks if the data is a Canon MakerNote. +// Canon has no header - detection is based on valid IFD structure. +// This should be called as a fallback after other manufacturers are ruled out. +func (h *Handler) Detect(data []byte) (bool, *makernote.Config) { + ok, cfg := makernote.DetectCanon(data) + if !ok { + return false, nil + } + return true, cfg +} + +// Parse extracts metadata from a Canon MakerNote. +func (h *Handler) Parse(r io.ReaderAt, makerNoteOffset, exifBase int64, cfg *makernote.Config) ([]parser.Tag, *parser.ParseError) { + parseErr := parser.NewParseError() + tags := make([]parser.Tag, 0) + + // Determine byte order - Canon inherits from parent + order := cfg.ByteOrder + if order == nil { + // Default to little-endian if not specified (most common for Canon) + order = binary.LittleEndian + } + + // Read IFD at makerNoteOffset + cfg.IFDOffset (0 for Canon) + ifdOffset := makerNoteOffset + cfg.IFDOffset + + // Read entry count + entryCountBuf := make([]byte, 2) + if _, err := r.ReadAt(entryCountBuf, ifdOffset); err != nil { + parseErr.Add(fmt.Errorf("canon: failed to read IFD entry count: %w", err)) + return nil, parseErr + } + entryCount := order.Uint16(entryCountBuf) + + // Sanity check + if entryCount == 0 || entryCount > 100 { + parseErr.Add(fmt.Errorf("canon: invalid entry count: %d", entryCount)) + return nil, parseErr + } + + // Read each IFD entry (12 bytes each) + entryOffset := ifdOffset + 2 + entryBuf := make([]byte, 12) + + for i := uint16(0); i < entryCount; i++ { + if _, err := r.ReadAt(entryBuf, entryOffset); err != nil { + parseErr.Add(fmt.Errorf("canon: failed to read IFD entry %d: %w", i, err)) + entryOffset += 12 + continue + } + + tagID := order.Uint16(entryBuf[0:2]) + tagType := order.Uint16(entryBuf[2:4]) + count := order.Uint32(entryBuf[4:8]) + valueOffset := order.Uint32(entryBuf[8:12]) + + tag, err := h.parseTag(r, order, tagID, tagType, count, valueOffset, exifBase) + if err != nil { + parseErr.Add(fmt.Errorf("canon: tag 0x%04X: %w", tagID, err)) + entryOffset += 12 + continue + } + + if tag != nil { + tags = append(tags, *tag) + } + + entryOffset += 12 + } + + return tags, parseErr.OrNil() +} + +// parseTag parses a single Canon tag. +func (h *Handler) parseTag(r io.ReaderAt, order binary.ByteOrder, tagID, tagType uint16, count, valueOffset uint32, exifBase int64) (*parser.Tag, error) { + tagName := GetTagName(tagID) + if tagName == "" { + tagName = fmt.Sprintf("0x%04X", tagID) + } + + // Calculate data size + typeSize := getTypeSize(tagType) + if typeSize == 0 { + return nil, fmt.Errorf("unknown type: %d", tagType) + } + + totalSize := int(count) * typeSize + if totalSize > 50*1024*1024 { // 50MB limit + return nil, fmt.Errorf("data size exceeds limit: %d", totalSize) + } + + // Read value + var value any + var err error + + if totalSize <= 4 { + // Inline value + value, err = h.readInlineValue(order, tagType, count, valueOffset) + } else { + // Value at offset (absolute, relative to EXIF TIFF header) + dataOffset := exifBase + int64(valueOffset) + value, err = h.readOffsetValue(r, order, tagType, count, dataOffset) + } + + if err != nil { + return nil, err + } + + return &parser.Tag{ + ID: parser.TagID(fmt.Sprintf("Canon:0x%04X", tagID)), + Name: tagName, + Value: value, + DataType: getTypeName(tagType), + }, nil +} + +// readInlineValue reads a value stored inline in the IFD entry. +func (h *Handler) readInlineValue(order binary.ByteOrder, tagType uint16, count, valueOffset uint32) (any, error) { + // Convert valueOffset to bytes for extraction + buf := make([]byte, 4) + order.PutUint32(buf, valueOffset) + + switch tagType { + case 1, 7: // BYTE, UNDEFINED + if count == 1 { + return buf[0], nil + } + return buf[:count], nil + + case 2: // ASCII + s := string(buf[:count]) + // Trim null terminator + for i := 0; i < len(s); i++ { + if s[i] == 0 { + return s[:i], nil + } + } + return s, nil + + case 3: // SHORT + if count == 1 { + return order.Uint16(buf[0:2]), nil + } + vals := make([]uint16, count) + for i := uint32(0); i < count && i < 2; i++ { + vals[i] = order.Uint16(buf[i*2 : i*2+2]) + } + return vals, nil + + case 4: // LONG + return valueOffset, nil + + case 6: // SBYTE + if count == 1 { + return int8(buf[0]), nil + } + vals := make([]int8, count) + for i := uint32(0); i < count && i < 4; i++ { + vals[i] = int8(buf[i]) + } + return vals, nil + + case 8: // SSHORT + if count == 1 { + return int16(order.Uint16(buf[0:2])), nil + } + vals := make([]int16, count) + for i := uint32(0); i < count && i < 2; i++ { + vals[i] = int16(order.Uint16(buf[i*2 : i*2+2])) + } + return vals, nil + + case 9: // SLONG + return int32(valueOffset), nil + + default: + return buf[:4], nil + } +} + +// readOffsetValue reads a value stored at an offset in the file. +func (h *Handler) readOffsetValue(r io.ReaderAt, order binary.ByteOrder, tagType uint16, count uint32, dataOffset int64) (any, error) { + typeSize := getTypeSize(tagType) + totalSize := int(count) * typeSize + + data := make([]byte, totalSize) + if _, err := r.ReadAt(data, dataOffset); err != nil { + return nil, fmt.Errorf("failed to read data at offset %d: %w", dataOffset, err) + } + + switch tagType { + case 1, 7: // BYTE, UNDEFINED + if count == 1 { + return data[0], nil + } + return data, nil + + case 2: // ASCII + // Trim null terminator + for i := 0; i < len(data); i++ { + if data[i] == 0 { + return string(data[:i]), nil + } + } + return string(data), nil + + case 3: // SHORT + vals := make([]uint16, count) + for i := uint32(0); i < count; i++ { + vals[i] = order.Uint16(data[i*2 : i*2+2]) + } + if count == 1 { + return vals[0], nil + } + return vals, nil + + case 4: // LONG + vals := make([]uint32, count) + for i := uint32(0); i < count; i++ { + vals[i] = order.Uint32(data[i*4 : i*4+4]) + } + if count == 1 { + return vals[0], nil + } + return vals, nil + + case 5: // RATIONAL + vals := make([]string, count) + for i := uint32(0); i < count; i++ { + num := order.Uint32(data[i*8 : i*8+4]) + denom := order.Uint32(data[i*8+4 : i*8+8]) + vals[i] = fmt.Sprintf("%d/%d", num, denom) + } + if count == 1 { + return vals[0], nil + } + return vals, nil + + case 6: // SBYTE + vals := make([]int8, count) + for i := uint32(0); i < count; i++ { + vals[i] = int8(data[i]) + } + if count == 1 { + return vals[0], nil + } + return vals, nil + + case 8: // SSHORT + vals := make([]int16, count) + for i := uint32(0); i < count; i++ { + vals[i] = int16(order.Uint16(data[i*2 : i*2+2])) + } + if count == 1 { + return vals[0], nil + } + return vals, nil + + case 9: // SLONG + vals := make([]int32, count) + for i := uint32(0); i < count; i++ { + vals[i] = int32(order.Uint32(data[i*4 : i*4+4])) + } + if count == 1 { + return vals[0], nil + } + return vals, nil + + case 10: // SRATIONAL + vals := make([]string, count) + for i := uint32(0); i < count; i++ { + num := int32(order.Uint32(data[i*8 : i*8+4])) + denom := int32(order.Uint32(data[i*8+4 : i*8+8])) + vals[i] = fmt.Sprintf("%d/%d", num, denom) + } + if count == 1 { + return vals[0], nil + } + return vals, nil + + default: + return data, nil + } +} + +// TagName returns the human-readable name for a Canon tag ID. +func (h *Handler) TagName(tagID uint16) string { + return GetTagName(tagID) +} + +// getTypeSize returns the size in bytes for a TIFF tag type. +func getTypeSize(tagType uint16) int { + switch tagType { + case 1, 2, 6, 7: // BYTE, ASCII, SBYTE, UNDEFINED + return 1 + case 3, 8: // SHORT, SSHORT + return 2 + case 4, 9, 11: // LONG, SLONG, FLOAT + return 4 + case 5, 10, 12: // RATIONAL, SRATIONAL, DOUBLE + return 8 + default: + return 0 + } +} + +// getTypeName returns the name for a TIFF tag type. +func getTypeName(tagType uint16) string { + switch tagType { + case 1: + return "BYTE" + case 2: + return "ASCII" + case 3: + return "SHORT" + case 4: + return "LONG" + case 5: + return "RATIONAL" + case 6: + return "SBYTE" + case 7: + return "UNDEFINED" + case 8: + return "SSHORT" + case 9: + return "SLONG" + case 10: + return "SRATIONAL" + case 11: + return "FLOAT" + case 12: + return "DOUBLE" + default: + return "UNKNOWN" + } +} diff --git a/internal/parser/tiff/makernote/canon/canon_test.go b/internal/parser/tiff/makernote/canon/canon_test.go new file mode 100644 index 0000000..4c3b6a5 --- /dev/null +++ b/internal/parser/tiff/makernote/canon/canon_test.go @@ -0,0 +1,416 @@ +package canon + +import ( + "bytes" + "encoding/binary" + "testing" + + "github.com/gomantics/imx/internal/parser/tiff/makernote" +) + +func TestHandler_Manufacturer(t *testing.T) { + h := New() + if got := h.Manufacturer(); got != "Canon" { + t.Errorf("Manufacturer() = %q, want %q", got, "Canon") + } +} + +func TestHandler_Detect(t *testing.T) { + h := New() + + tests := []struct { + name string + data []byte + wantMatch bool + }{ + { + name: "valid Canon IFD with 2 entries", + data: makeCanonIFD(2), + wantMatch: true, + }, + { + name: "valid Canon IFD with 10 entries", + data: makeCanonIFD(10), + wantMatch: true, + }, + { + name: "too few entries (0)", + data: makeCanonIFD(0), + wantMatch: false, + }, + { + name: "too many entries (101)", + data: []byte{101, 0}, + wantMatch: false, + }, + { + name: "too short", + data: []byte{2, 0}, // claims 2 entries but no data + wantMatch: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + match, cfg := h.Detect(tt.data) + if match != tt.wantMatch { + t.Errorf("Detect() = %v, want %v", match, tt.wantMatch) + } + if match { + if cfg.IFDOffset != 0 { + t.Errorf("cfg.IFDOffset = %d, want 0", cfg.IFDOffset) + } + if cfg.OffsetBase != makernote.OffsetAbsolute { + t.Errorf("cfg.OffsetBase = %v, want OffsetAbsolute", cfg.OffsetBase) + } + } + }) + } +} + +func TestHandler_Parse(t *testing.T) { + h := New() + order := binary.LittleEndian + + tests := []struct { + name string + data []byte + cfg *makernote.Config + wantTags int + wantErr bool + checkValues map[string]any + }{ + { + name: "parse ImageType tag", + data: func() []byte { + buf := new(bytes.Buffer) + // Entry count: 1 (2 bytes) + binary.Write(buf, order, uint16(1)) + // Entry: ImageType (0x0006), ASCII, count=10, offset=18 (12 bytes) + // Offset = 2 (count) + 12 (entry) + 4 (next IFD) = 18 + writeIFDEntry(buf, order, TagImageType, 2, 10, 18) + // Next IFD offset (ignored) (4 bytes) + binary.Write(buf, order, uint32(0)) + // Actual string data at offset 18 + buf.WriteString("Canon EOS\x00") + return buf.Bytes() + }(), + cfg: &makernote.Config{ + IFDOffset: 0, + OffsetBase: makernote.OffsetAbsolute, + ByteOrder: order, + }, + wantTags: 1, + checkValues: map[string]any{ + "ImageType": "Canon EOS", + }, + }, + { + name: "parse SerialNumber tag", + data: func() []byte { + buf := new(bytes.Buffer) + // Entry count: 1 (2 bytes) + binary.Write(buf, order, uint16(1)) + // Entry: SerialNumber (0x000C), ASCII, count=12, offset=18 (12 bytes) + writeIFDEntry(buf, order, TagSerialNumber, 2, 12, 18) + // Next IFD offset (4 bytes) + binary.Write(buf, order, uint32(0)) + // String data at offset 18 + buf.WriteString("123456789\x00\x00\x00") + return buf.Bytes() + }(), + cfg: &makernote.Config{ + IFDOffset: 0, + OffsetBase: makernote.OffsetAbsolute, + ByteOrder: order, + }, + wantTags: 1, + checkValues: map[string]any{ + "SerialNumber": "123456789", + }, + }, + { + name: "parse inline SHORT value", + data: func() []byte { + buf := new(bytes.Buffer) + // Entry count: 1 + binary.Write(buf, order, uint16(1)) + // Entry: ModelID (0x0010), SHORT, count=1, value=0x1234 inline + writeIFDEntry(buf, order, TagModelID, 3, 1, 0x1234) + // Next IFD offset + binary.Write(buf, order, uint32(0)) + return buf.Bytes() + }(), + cfg: &makernote.Config{ + IFDOffset: 0, + OffsetBase: makernote.OffsetAbsolute, + ByteOrder: order, + }, + wantTags: 1, + checkValues: map[string]any{ + "ModelID": uint16(0x1234), + }, + }, + { + name: "parse CameraSettings1 short array", + data: func() []byte { + buf := new(bytes.Buffer) + // Entry count: 1 (2 bytes) + binary.Write(buf, order, uint16(1)) + // Entry: CameraSettings1 (0x0001), SHORT array, count=5, offset=18 (12 bytes) + writeIFDEntry(buf, order, TagCameraSettings1, 3, 5, 18) + // Next IFD offset (4 bytes) + binary.Write(buf, order, uint32(0)) + // Array data at offset 18 (5 shorts = 10 bytes) + for i := uint16(1); i <= 5; i++ { + binary.Write(buf, order, i*100) + } + return buf.Bytes() + }(), + cfg: &makernote.Config{ + IFDOffset: 0, + OffsetBase: makernote.OffsetAbsolute, + ByteOrder: order, + }, + wantTags: 1, + checkValues: map[string]any{ + "CameraSettings1": []uint16{100, 200, 300, 400, 500}, + }, + }, + { + name: "parse multiple tags", + data: func() []byte { + buf := new(bytes.Buffer) + // Entry count: 3 (2 bytes) + binary.Write(buf, order, uint16(3)) + // Entry 1: ImageType at offset 42 (12 bytes) + // Offset = 2 + 3*12 + 4 = 42 + writeIFDEntry(buf, order, TagImageType, 2, 10, 42) + // Entry 2: ModelID inline (12 bytes) + writeIFDEntry(buf, order, TagModelID, 3, 1, 0x80000001) + // Entry 3: LensModel at offset 52 (12 bytes) + writeIFDEntry(buf, order, TagLensModel, 2, 15, 52) + // Next IFD offset (4 bytes) + binary.Write(buf, order, uint32(0)) + // ImageType data at offset 42 (10 bytes) + buf.WriteString("Canon EOS\x00") + // LensModel data at offset 52 (15 bytes) + buf.WriteString("EF 50mm f/1.4\x00\x00") + return buf.Bytes() + }(), + cfg: &makernote.Config{ + IFDOffset: 0, + OffsetBase: makernote.OffsetAbsolute, + ByteOrder: order, + }, + wantTags: 3, + }, + { + name: "invalid entry count 0", + data: func() []byte { + buf := new(bytes.Buffer) + binary.Write(buf, order, uint16(0)) + return buf.Bytes() + }(), + cfg: &makernote.Config{ + IFDOffset: 0, + OffsetBase: makernote.OffsetAbsolute, + ByteOrder: order, + }, + wantTags: 0, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reader := bytes.NewReader(tt.data) + tags, parseErr := h.Parse(reader, 0, 0, tt.cfg) + + if tt.wantErr { + if parseErr == nil { + t.Error("expected error, got nil") + } + return + } + + if len(tags) != tt.wantTags { + t.Errorf("got %d tags, want %d", len(tags), tt.wantTags) + } + + // Check specific values + for wantName, wantValue := range tt.checkValues { + found := false + for _, tag := range tags { + if tag.Name == wantName { + found = true + if !compareValues(tag.Value, wantValue) { + t.Errorf("tag %s: got %v (%T), want %v (%T)", + wantName, tag.Value, tag.Value, wantValue, wantValue) + } + break + } + } + if !found { + t.Errorf("tag %s not found", wantName) + } + } + }) + } +} + +func TestHandler_TagName(t *testing.T) { + h := New() + + tests := []struct { + tagID uint16 + want string + }{ + {TagCameraSettings1, "CameraSettings1"}, + {TagImageType, "ImageType"}, + {TagSerialNumber, "SerialNumber"}, + {TagLensModel, "LensModel"}, + {0xFFFF, ""}, // Unknown + } + + for _, tt := range tests { + got := h.TagName(tt.tagID) + if got != tt.want { + t.Errorf("TagName(0x%04X) = %q, want %q", tt.tagID, got, tt.want) + } + } +} + +func TestGetTagName(t *testing.T) { + tests := []struct { + tagID uint16 + want string + }{ + {TagCameraSettings1, "CameraSettings1"}, + {TagCameraSettings2, "CameraSettings2"}, + {TagImageType, "ImageType"}, + {TagFirmwareVersion, "FirmwareVersion"}, + {TagOwnerName, "OwnerName"}, + {TagSerialNumber, "SerialNumber"}, + {TagModelID, "ModelID"}, + {TagLensModel, "LensModel"}, + {TagColorData, "ColorData"}, + {0x9999, ""}, // Unknown + } + + for _, tt := range tests { + got := GetTagName(tt.tagID) + if got != tt.want { + t.Errorf("GetTagName(0x%04X) = %q, want %q", tt.tagID, got, tt.want) + } + } +} + +func TestGetTypeSize(t *testing.T) { + tests := []struct { + tagType uint16 + want int + }{ + {1, 1}, // BYTE + {2, 1}, // ASCII + {3, 2}, // SHORT + {4, 4}, // LONG + {5, 8}, // RATIONAL + {6, 1}, // SBYTE + {7, 1}, // UNDEFINED + {8, 2}, // SSHORT + {9, 4}, // SLONG + {10, 8}, // SRATIONAL + {11, 4}, // FLOAT + {12, 8}, // DOUBLE + {99, 0}, // Unknown + } + + for _, tt := range tests { + got := getTypeSize(tt.tagType) + if got != tt.want { + t.Errorf("getTypeSize(%d) = %d, want %d", tt.tagType, got, tt.want) + } + } +} + +func TestGetTypeName(t *testing.T) { + tests := []struct { + tagType uint16 + want string + }{ + {1, "BYTE"}, + {2, "ASCII"}, + {3, "SHORT"}, + {4, "LONG"}, + {5, "RATIONAL"}, + {6, "SBYTE"}, + {7, "UNDEFINED"}, + {8, "SSHORT"}, + {9, "SLONG"}, + {10, "SRATIONAL"}, + {11, "FLOAT"}, + {12, "DOUBLE"}, + {99, "UNKNOWN"}, + } + + for _, tt := range tests { + got := getTypeName(tt.tagType) + if got != tt.want { + t.Errorf("getTypeName(%d) = %q, want %q", tt.tagType, got, tt.want) + } + } +} + +// Helper functions + +func makeCanonIFD(entryCount uint16) []byte { + order := binary.LittleEndian + // IFD: 2-byte count + 12-byte entries + 4-byte next offset + size := 2 + int(entryCount)*12 + 4 + data := make([]byte, size) + order.PutUint16(data[0:2], entryCount) + // Fill entries with dummy data + for i := uint16(0); i < entryCount; i++ { + offset := 2 + int(i)*12 + order.PutUint16(data[offset:offset+2], 0x0001+i) // Tag ID + order.PutUint16(data[offset+2:offset+4], 3) // Type: SHORT + order.PutUint32(data[offset+4:offset+8], 1) // Count + order.PutUint32(data[offset+8:offset+12], uint32(i)) // Value + } + return data +} + +func writeIFDEntry(buf *bytes.Buffer, order binary.ByteOrder, tag, tagType uint16, count, value uint32) { + binary.Write(buf, order, tag) + binary.Write(buf, order, tagType) + binary.Write(buf, order, count) + binary.Write(buf, order, value) +} + +func compareValues(got, want any) bool { + switch w := want.(type) { + case string: + g, ok := got.(string) + return ok && g == w + case uint16: + g, ok := got.(uint16) + return ok && g == w + case uint32: + g, ok := got.(uint32) + return ok && g == w + case []uint16: + g, ok := got.([]uint16) + if !ok || len(g) != len(w) { + return false + } + for i := range w { + if g[i] != w[i] { + return false + } + } + return true + default: + return false + } +} diff --git a/internal/parser/tiff/makernote/canon/tags.go b/internal/parser/tiff/makernote/canon/tags.go new file mode 100644 index 0000000..9cc83ec --- /dev/null +++ b/internal/parser/tiff/makernote/canon/tags.go @@ -0,0 +1,88 @@ +package canon + +// Canon MakerNote tag IDs +const ( + TagCameraSettings1 uint16 = 0x0001 // Camera settings array 1 + TagFocalLength uint16 = 0x0002 // Focal length info + TagFlashInfo uint16 = 0x0003 // Flash information + TagCameraSettings2 uint16 = 0x0004 // Camera settings array 2 (ShotInfo) + TagPanorama uint16 = 0x0005 // Panorama info + TagImageType uint16 = 0x0006 // Camera model string + TagFirmwareVersion uint16 = 0x0007 // Firmware version + TagFileNumber uint16 = 0x0008 // File number + TagOwnerName uint16 = 0x0009 // Owner name + TagSerialNumber uint16 = 0x000C // Camera serial number + TagCameraInfo uint16 = 0x000D // Camera info + TagFileLength uint16 = 0x000E // File length + TagCustomFunctions uint16 = 0x000F // Custom functions + TagModelID uint16 = 0x0010 // Canon model ID + TagMovieInfo uint16 = 0x0011 // Movie info + TagAFInfo uint16 = 0x0012 // AF info + TagThumbnailOffset uint16 = 0x0081 // Thumbnail image offset + TagThumbnailLength uint16 = 0x0082 // Thumbnail image length + TagLensModel uint16 = 0x0095 // Lens model string + TagInternalSerial uint16 = 0x0096 // Internal serial number + TagDustRemoval uint16 = 0x0097 // Dust removal data + TagCropInfo uint16 = 0x0098 // Crop info + TagAspectInfo uint16 = 0x009A // Aspect ratio info + TagColorInfo uint16 = 0x00A0 // Processing info + TagVRDOffset uint16 = 0x00D0 // VRD recipe offset + TagSensorInfo uint16 = 0x00E0 // Sensor info + TagColorData uint16 = 0x4001 // Color data + TagCRWParam uint16 = 0x4002 // CRW parameters + TagColorInfo2 uint16 = 0x4003 // Color info 2 + TagFlavor uint16 = 0x4005 // Picture style + TagPictureStylePC uint16 = 0x4008 // Picture style user def + TagVignettingCorr uint16 = 0x4015 // Vignetting correction + TagLensInfo uint16 = 0x4019 // Lens info + TagAmbienceInfo uint16 = 0x4020 // Ambience info + TagFilterInfo uint16 = 0x4024 // Filter info +) + +// tagNames maps Canon tag IDs to human-readable names +var tagNames = map[uint16]string{ + TagCameraSettings1: "CameraSettings1", + TagFocalLength: "FocalLength", + TagFlashInfo: "FlashInfo", + TagCameraSettings2: "CameraSettings2", + TagPanorama: "Panorama", + TagImageType: "ImageType", + TagFirmwareVersion: "FirmwareVersion", + TagFileNumber: "FileNumber", + TagOwnerName: "OwnerName", + TagSerialNumber: "SerialNumber", + TagCameraInfo: "CameraInfo", + TagFileLength: "FileLength", + TagCustomFunctions: "CustomFunctions", + TagModelID: "ModelID", + TagMovieInfo: "MovieInfo", + TagAFInfo: "AFInfo", + TagThumbnailOffset: "ThumbnailImageValidArea", + TagThumbnailLength: "ThumbnailImageLength", + TagLensModel: "LensModel", + TagInternalSerial: "InternalSerialNumber", + TagDustRemoval: "DustRemovalData", + TagCropInfo: "CropInfo", + TagAspectInfo: "AspectInfo", + TagColorInfo: "ColorInfo", + TagVRDOffset: "VRDOffset", + TagSensorInfo: "SensorInfo", + TagColorData: "ColorData", + TagCRWParam: "CRWParam", + TagColorInfo2: "ColorInfo2", + TagFlavor: "PictureStyleUserDef", + TagPictureStylePC: "PictureStylePC", + TagVignettingCorr: "VignettingCorrection", + TagLensInfo: "LensInfo", + TagAmbienceInfo: "AmbienceInfo", + TagFilterInfo: "FilterInfo", +} + +// GetTagName returns the human-readable name for a Canon tag ID. +// Returns empty string if tag is not recognized. +func GetTagName(tagID uint16) string { + if name, ok := tagNames[tagID]; ok { + return name + } + return "" +} diff --git a/internal/parser/tiff/tiff.go b/internal/parser/tiff/tiff.go index 58fc7d9..2b23eba 100644 --- a/internal/parser/tiff/tiff.go +++ b/internal/parser/tiff/tiff.go @@ -10,6 +10,7 @@ import ( "github.com/gomantics/imx/internal/parser/icc" "github.com/gomantics/imx/internal/parser/iptc" "github.com/gomantics/imx/internal/parser/tiff/makernote" + "github.com/gomantics/imx/internal/parser/tiff/makernote/canon" "github.com/gomantics/imx/internal/parser/xmp" ) @@ -41,11 +42,16 @@ type Parser struct { // New creates a new TIFF parser func New() *Parser { + // Create MakerNote registry with handlers in priority order + // Canon must be last (no header, uses IFD validation as fallback) + mnRegistry := makernote.NewRegistry() + mnRegistry.Register(canon.New()) + return &Parser{ icc: icc.New(), iptc: iptc.New(), xmp: xmp.New(), - makernote: makernote.NewRegistry(), + makernote: mnRegistry, } }