diff --git a/api_integration_test.go b/api_integration_test.go index ec01e58..f83ae2c 100644 --- a/api_integration_test.go +++ b/api_integration_test.go @@ -265,7 +265,7 @@ func TestIntegration_CR2(t *testing.T) { }, { Name: "ExifIFD", - ExactTagCount: 27, + ExactTagCount: 26, // MakerNote is now parsed into Canon directory Tags: []imxtest.TagExpectation{ // All tags with exact values where possible, binary data checked for presence only {Name: "ExposureTime", Value: "1/100"}, @@ -282,7 +282,7 @@ func TestIntegration_CR2(t *testing.T) { {Name: "MeteringMode", Value: "Multi-segment"}, {Name: "Flash", Value: "Off, Did not fire"}, {Name: "FocalLength", Value: "85/1"}, - {Name: "MakerNote", Type: "[]byte"}, // Binary data - check presence only + // MakerNote is now parsed into Canon directory {Name: "UserComment", Type: "[]byte"}, // Binary data - check presence only {Name: "FlashpixVersion", Value: []byte{48, 49, 48, 48}}, // "0100" in hex {Name: "ColorSpace", Value: "sRGB"}, @@ -305,6 +305,10 @@ func TestIntegration_CR2(t *testing.T) { {Name: "JPEGInterchangeFormatLength", Value: uint32(13120)}, }, }, + { + Name: "Canon", + ExactTagCount: -1, // Presence check only - Canon MakerNote content varies by camera model + }, }) if result.Failed() { for _, err := range result.Errors { diff --git a/internal/parser/tiff/makernote/fujifilm/fujifilm.go b/internal/parser/tiff/makernote/fujifilm/fujifilm.go new file mode 100644 index 0000000..e58db99 --- /dev/null +++ b/internal/parser/tiff/makernote/fujifilm/fujifilm.go @@ -0,0 +1,371 @@ +// Package fujifilm implements Fujifilm MakerNote parsing. +// +// Fujifilm MakerNote format: +// - Header: 'FUJIFILM' (8 bytes) + 4-byte IFD offset (little-endian) +// - IFD starts at the offset specified in header (typically 12) +// - Offsets are RELATIVE TO MAKERNOTE START (key difference from Canon/Sony) +// - Always little-endian +// - No next-IFD pointer +package fujifilm + +import ( + "encoding/binary" + "fmt" + "io" + + imxbin "github.com/gomantics/imx/internal/binary" + "github.com/gomantics/imx/internal/parser" + "github.com/gomantics/imx/internal/parser/tiff/makernote" +) + +// Handler implements makernote.Handler for Fujifilm cameras. +type Handler struct{} + +// New creates a new Fujifilm MakerNote handler. +func New() *Handler { + return &Handler{} +} + +// Manufacturer returns "Fujifilm". +func (h *Handler) Manufacturer() string { + return "Fujifilm" +} + +// Detect checks if the data is a Fujifilm MakerNote. +func (h *Handler) Detect(data []byte) (bool, *makernote.Config) { + return makernote.DetectFujifilm(data) +} + +// Parse extracts metadata from Fujifilm MakerNote. +func (h *Handler) Parse(r io.ReaderAt, makerNoteOffset, exifBase int64, cfg *makernote.Config) ([]parser.Tag, *parser.ParseError) { + parseErr := parser.NewParseError() + + // Fujifilm is always little-endian + order := cfg.ByteOrder + if order == nil { + order = binary.LittleEndian + } + reader := imxbin.NewReader(r, order) + + // Calculate IFD start position + ifdOffset := makerNoteOffset + cfg.IFDOffset + + // Read number of IFD entries + numEntries, err := reader.ReadUint16(ifdOffset) + if err != nil { + parseErr.Add(fmt.Errorf("failed to read Fujifilm IFD entry count: %w", err)) + return nil, parseErr + } + + // Sanity check entry count + if numEntries == 0 || numEntries > 200 { + parseErr.Add(fmt.Errorf("invalid Fujifilm IFD entry count: %d", numEntries)) + return nil, parseErr + } + + tags := make([]parser.Tag, 0, numEntries) + entryOffset := ifdOffset + 2 // Skip entry count + + for i := uint16(0); i < numEntries; i++ { + tag, err := h.parseEntry(reader, entryOffset, makerNoteOffset, exifBase, cfg) + if err != nil { + parseErr.Add(fmt.Errorf("failed to parse Fujifilm tag at offset %d: %w", entryOffset, err)) + } else if tag != nil { + tags = append(tags, *tag) + } + entryOffset += 12 // Each IFD entry is 12 bytes + } + + return tags, parseErr +} + +// parseEntry parses a single IFD entry. +func (h *Handler) parseEntry(r *imxbin.Reader, offset, makerNoteOffset, exifBase int64, cfg *makernote.Config) (*parser.Tag, error) { + // Read tag ID + tagID, err := r.ReadUint16(offset) + if err != nil { + return nil, err + } + + // Read type + typeVal, err := r.ReadUint16(offset + 2) + if err != nil { + return nil, err + } + + // Read count + count, err := r.ReadUint32(offset + 4) + if err != nil { + return nil, err + } + + // Read value/offset + valueOffset, err := r.ReadUint32(offset + 8) + if err != nil { + return nil, err + } + + // Calculate data size + typeSize := getTypeSize(typeVal) + if typeSize == 0 { + return nil, nil // Unknown type, skip + } + + totalSize := int(count) * typeSize + + // Determine where to read the value from + var value any + if totalSize <= 4 { + // Value is inline + value, err = h.readInlineValue(r, valueOffset, typeVal, count) + } else { + // Value is at offset - KEY DIFFERENCE: relative to MakerNote start + dataOffset := h.resolveOffset(int64(valueOffset), makerNoteOffset, exifBase, cfg) + value, err = h.readValue(r, dataOffset, typeVal, count) + } + + if err != nil { + return nil, err + } + + tagName := h.TagName(tagID) + if tagName == "" { + tagName = fmt.Sprintf("0x%04X", tagID) + } + + return &parser.Tag{ + ID: parser.TagID(fmt.Sprintf("Fujifilm:0x%04X", tagID)), + Name: tagName, + Value: value, + DataType: getTypeName(typeVal), + }, nil +} + +// resolveOffset calculates the absolute file offset for a tag value. +// Fujifilm uses offsets relative to MakerNote start. +func (h *Handler) resolveOffset(tagOffset int64, makerNoteOffset, exifBase int64, cfg *makernote.Config) int64 { + switch cfg.OffsetBase { + case makernote.OffsetAbsolute: + return exifBase + tagOffset + case makernote.OffsetRelativeToMakerNote: + // Fujifilm: offsets are relative to MakerNote start + return makerNoteOffset + tagOffset + default: + return tagOffset + } +} + +// readInlineValue reads a value stored inline in the value/offset field. +func (h *Handler) readInlineValue(r *imxbin.Reader, valueOffset uint32, typeVal uint16, count uint32) (any, error) { + buf := make([]byte, 4) + r.PutUint32(buf, valueOffset) + + switch typeVal { + case 1, 7: // BYTE, UNDEFINED + if count == 1 { + return buf[0], nil + } + return buf[:count], nil + case 2: // ASCII + return string(buf[:count-1]), nil // Exclude null terminator + case 3: // SHORT + if count == 1 { + return r.Uint16(buf[0:2]), nil + } + vals := make([]uint16, count) + vals[0] = r.Uint16(buf[0:2]) + if count > 1 { + vals[1] = r.Uint16(buf[2:4]) + } + return vals, nil + case 4: // LONG + return valueOffset, nil + case 8: // SSHORT + if count == 1 { + return int16(r.Uint16(buf[0:2])), nil + } + vals := make([]int16, count) + vals[0] = int16(r.Uint16(buf[0:2])) + if count > 1 { + vals[1] = int16(r.Uint16(buf[2:4])) + } + return vals, nil + case 9: // SLONG + return int32(valueOffset), nil + default: + return buf[:4], nil + } +} + +// readValue reads a value from file at the given offset. +func (h *Handler) readValue(r *imxbin.Reader, offset int64, typeVal uint16, count uint32) (any, error) { + switch typeVal { + case 1, 7: // BYTE, UNDEFINED + data, err := r.ReadBytes(offset, int(count)) + if err != nil { + return nil, err + } + if count == 1 { + return data[0], nil + } + return data, nil + case 2: // ASCII + data, err := r.ReadBytes(offset, int(count)) + if err != nil { + return nil, err + } + // Trim null terminator + for len(data) > 0 && data[len(data)-1] == 0 { + data = data[:len(data)-1] + } + return string(data), nil + case 3: // SHORT + vals := make([]uint16, count) + for i := uint32(0); i < count; i++ { + val, err := r.ReadUint16(offset + int64(i)*2) + if err != nil { + return nil, err + } + vals[i] = val + } + if count == 1 { + return vals[0], nil + } + return vals, nil + case 4: // LONG + vals := make([]uint32, count) + for i := uint32(0); i < count; i++ { + val, err := r.ReadUint32(offset + int64(i)*4) + if err != nil { + return nil, err + } + vals[i] = val + } + 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, err := r.ReadUint32(offset + int64(i)*8) + if err != nil { + return nil, err + } + denom, err := r.ReadUint32(offset + int64(i)*8 + 4) + if err != nil { + return nil, err + } + vals[i] = fmt.Sprintf("%d/%d", num, denom) + } + if count == 1 { + return vals[0], nil + } + return vals, nil + case 8: // SSHORT + vals := make([]int16, count) + for i := uint32(0); i < count; i++ { + val, err := r.ReadInt16(offset + int64(i)*2) + if err != nil { + return nil, err + } + vals[i] = val + } + if count == 1 { + return vals[0], nil + } + return vals, nil + case 9: // SLONG + vals := make([]int32, count) + for i := uint32(0); i < count; i++ { + val, err := r.ReadInt32(offset + int64(i)*4) + if err != nil { + return nil, err + } + vals[i] = val + } + 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, err := r.ReadInt32(offset + int64(i)*8) + if err != nil { + return nil, err + } + denom, err := r.ReadInt32(offset + int64(i)*8 + 4) + if err != nil { + return nil, err + } + vals[i] = fmt.Sprintf("%d/%d", num, denom) + } + if count == 1 { + return vals[0], nil + } + return vals, nil + default: + data, err := r.ReadBytes(offset, int(count)*getTypeSize(typeVal)) + if err != nil { + return nil, err + } + return data, nil + } +} + +// TagName returns the human-readable name for a Fujifilm tag. +func (h *Handler) TagName(tagID uint16) string { + if name, ok := fujifilmTagNames[tagID]; ok { + return name + } + return "" +} + +// getTypeSize returns the size in bytes for a TIFF type. +func getTypeSize(typeVal uint16) int { + switch typeVal { + 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 string name for a TIFF type. +func getTypeName(typeVal uint16) string { + switch typeVal { + 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/fujifilm/fujifilm_test.go b/internal/parser/tiff/makernote/fujifilm/fujifilm_test.go new file mode 100644 index 0000000..fecebce --- /dev/null +++ b/internal/parser/tiff/makernote/fujifilm/fujifilm_test.go @@ -0,0 +1,349 @@ +package fujifilm + +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 != "Fujifilm" { + t.Errorf("Manufacturer() = %q, want %q", got, "Fujifilm") + } +} + +func TestHandler_Detect(t *testing.T) { + h := New() + + tests := []struct { + name string + data []byte + wantOK bool + wantCfg *makernote.Config + }{ + { + name: "FUJIFILM header with offset 12", + data: func() []byte { + data := make([]byte, 100) + copy(data[0:8], []byte("FUJIFILM")) + binary.LittleEndian.PutUint32(data[8:12], 12) // IFD offset + return data + }(), + wantOK: true, + wantCfg: &makernote.Config{ + IFDOffset: 12, + OffsetBase: makernote.OffsetRelativeToMakerNote, + ByteOrder: binary.LittleEndian, + HasNextIFD: false, + Variant: "Standard", + }, + }, + { + name: "FUJIFILM header with offset 20", + data: func() []byte { + data := make([]byte, 100) + copy(data[0:8], []byte("FUJIFILM")) + binary.LittleEndian.PutUint32(data[8:12], 20) // Different IFD offset + return data + }(), + wantOK: true, + wantCfg: &makernote.Config{ + IFDOffset: 20, + OffsetBase: makernote.OffsetRelativeToMakerNote, + ByteOrder: binary.LittleEndian, + }, + }, + { + name: "not Fujifilm - Sony", + data: []byte("SONY DSC xxxxxxx"), + wantOK: false, + }, + { + name: "not Fujifilm - Canon", + data: []byte{0x05, 0x00, 0x01, 0x00, 0x02, 0x00}, + wantOK: false, + }, + { + name: "too short", + data: []byte("FUJI"), + wantOK: false, + }, + { + name: "empty data", + data: []byte{}, + wantOK: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotOK, gotCfg := h.Detect(tt.data) + if gotOK != tt.wantOK { + t.Errorf("Detect() ok = %v, want %v", gotOK, tt.wantOK) + } + if tt.wantOK && gotCfg != nil { + if gotCfg.IFDOffset != tt.wantCfg.IFDOffset { + t.Errorf("Config.IFDOffset = %d, want %d", gotCfg.IFDOffset, tt.wantCfg.IFDOffset) + } + if gotCfg.OffsetBase != tt.wantCfg.OffsetBase { + t.Errorf("Config.OffsetBase = %v, want %v", gotCfg.OffsetBase, tt.wantCfg.OffsetBase) + } + if gotCfg.ByteOrder != tt.wantCfg.ByteOrder { + t.Errorf("Config.ByteOrder = %v, want %v", gotCfg.ByteOrder, tt.wantCfg.ByteOrder) + } + } + }) + } +} + +func TestHandler_TagName(t *testing.T) { + h := New() + + tests := []struct { + tagID uint16 + expected string + }{ + {0x0010, "SerialNumber"}, + {0x1000, "Quality"}, + {0x1021, "FocusMode"}, + {0x1022, "AFMode"}, + {0x1401, "FilmMode"}, + {0x1002, "WhiteBalance"}, + {0x1400, "DynamicRange"}, + {0x9999, ""}, // Unknown tag + } + + for _, tt := range tests { + name := tt.expected + if name == "" { + name = "unknown" + } + t.Run(name, func(t *testing.T) { + got := h.TagName(tt.tagID) + if got != tt.expected { + t.Errorf("TagName(0x%04X) = %q, want %q", tt.tagID, got, tt.expected) + } + }) + } +} + +func TestHandler_Parse(t *testing.T) { + h := New() + + // Build a minimal Fujifilm MakerNote with header and one IFD entry + // Header: "FUJIFILM" (8 bytes) + IFD offset (4 bytes) + // Entry count: 1 (2 bytes) + // Entry: tag=0x1000, type=SHORT(3), count=1, value=1 (Quality) + data := make([]byte, 100) + copy(data[0:8], []byte("FUJIFILM")) + binary.LittleEndian.PutUint32(data[8:12], 12) // IFD at offset 12 + + // IFD at offset 12 + binary.LittleEndian.PutUint16(data[12:14], 1) // 1 entry + + // Entry at offset 14 + binary.LittleEndian.PutUint16(data[14:16], 0x1000) // Tag: Quality + binary.LittleEndian.PutUint16(data[16:18], 3) // Type: SHORT + binary.LittleEndian.PutUint32(data[18:22], 1) // Count: 1 + binary.LittleEndian.PutUint32(data[22:26], 1) // Value: 1 (inline) + + reader := bytes.NewReader(data) + cfg := &makernote.Config{ + IFDOffset: 12, + OffsetBase: makernote.OffsetRelativeToMakerNote, + ByteOrder: binary.LittleEndian, + } + + tags, parseErr := h.Parse(reader, 0, 0, cfg) + + if parseErr != nil && parseErr.OrNil() != nil { + t.Errorf("Parse() returned errors: %v", parseErr) + } + + if len(tags) != 1 { + t.Fatalf("Parse() returned %d tags, want 1", len(tags)) + } + + tag := tags[0] + if tag.Name != "Quality" { + t.Errorf("Tag.Name = %q, want %q", tag.Name, "Quality") + } + if tag.ID != "Fujifilm:0x1000" { + t.Errorf("Tag.ID = %q, want %q", tag.ID, "Fujifilm:0x1000") + } + if val, ok := tag.Value.(uint16); !ok || val != 1 { + t.Errorf("Tag.Value = %v (%T), want 1 (uint16)", tag.Value, tag.Value) + } +} + +func TestHandler_Parse_MultipleEntries(t *testing.T) { + h := New() + + // Build Fujifilm MakerNote with 3 entries + data := make([]byte, 200) + copy(data[0:8], []byte("FUJIFILM")) + binary.LittleEndian.PutUint32(data[8:12], 12) // IFD at offset 12 + + // IFD at offset 12 + binary.LittleEndian.PutUint16(data[12:14], 3) // 3 entries + + // Entry 1: Quality (0x1000), SHORT, value=2 + binary.LittleEndian.PutUint16(data[14:16], 0x1000) + binary.LittleEndian.PutUint16(data[16:18], 3) + binary.LittleEndian.PutUint32(data[18:22], 1) + binary.LittleEndian.PutUint32(data[22:26], 2) + + // Entry 2: FocusMode (0x1021), SHORT, value=1 + binary.LittleEndian.PutUint16(data[26:28], 0x1021) + binary.LittleEndian.PutUint16(data[28:30], 3) + binary.LittleEndian.PutUint32(data[30:34], 1) + binary.LittleEndian.PutUint32(data[34:38], 1) + + // Entry 3: FilmMode (0x1401), SHORT, value=0 + binary.LittleEndian.PutUint16(data[38:40], 0x1401) + binary.LittleEndian.PutUint16(data[40:42], 3) + binary.LittleEndian.PutUint32(data[42:46], 1) + binary.LittleEndian.PutUint32(data[46:50], 0) + + reader := bytes.NewReader(data) + cfg := &makernote.Config{ + IFDOffset: 12, + OffsetBase: makernote.OffsetRelativeToMakerNote, + ByteOrder: binary.LittleEndian, + } + + tags, parseErr := h.Parse(reader, 0, 0, cfg) + + if parseErr != nil && parseErr.OrNil() != nil { + t.Errorf("Parse() returned errors: %v", parseErr) + } + + if len(tags) != 3 { + t.Fatalf("Parse() returned %d tags, want 3", len(tags)) + } + + expectedTags := []struct { + name string + value uint16 + }{ + {"Quality", 2}, + {"FocusMode", 1}, + {"FilmMode", 0}, + } + + for i, expected := range expectedTags { + if tags[i].Name != expected.name { + t.Errorf("tags[%d].Name = %q, want %q", i, tags[i].Name, expected.name) + } + if val, ok := tags[i].Value.(uint16); !ok || val != expected.value { + t.Errorf("tags[%d].Value = %v, want %d", i, tags[i].Value, expected.value) + } + } +} + +func TestHandler_Parse_RelativeOffset(t *testing.T) { + h := New() + + // Test that offsets are correctly calculated relative to MakerNote start + // Build MakerNote with one tag that has value at external offset + data := make([]byte, 200) + copy(data[0:8], []byte("FUJIFILM")) + binary.LittleEndian.PutUint32(data[8:12], 12) + + // IFD at offset 12 + binary.LittleEndian.PutUint16(data[12:14], 1) + + // Entry: SerialNumber (0x0010), ASCII, count=10 + // Value offset 100 (relative to MakerNote start, so absolute 100) + binary.LittleEndian.PutUint16(data[14:16], 0x0010) // Tag: SerialNumber + binary.LittleEndian.PutUint16(data[16:18], 2) // Type: ASCII + binary.LittleEndian.PutUint32(data[18:22], 10) // Count: 10 + binary.LittleEndian.PutUint32(data[22:26], 100) // Offset: 100 (relative) + + // Put serial number at offset 100 + copy(data[100:110], []byte("1234567890")) + + reader := bytes.NewReader(data) + cfg := &makernote.Config{ + IFDOffset: 12, + OffsetBase: makernote.OffsetRelativeToMakerNote, + ByteOrder: binary.LittleEndian, + } + + // makerNoteOffset = 0, so relative offset 100 becomes absolute offset 100 + tags, parseErr := h.Parse(reader, 0, 0, cfg) + + if parseErr != nil && parseErr.OrNil() != nil { + t.Errorf("Parse() returned errors: %v", parseErr) + } + + if len(tags) != 1 { + t.Fatalf("Parse() returned %d tags, want 1", len(tags)) + } + + if tags[0].Name != "SerialNumber" { + t.Errorf("Tag.Name = %q, want %q", tags[0].Name, "SerialNumber") + } + if val, ok := tags[0].Value.(string); !ok || val != "1234567890" { + t.Errorf("Tag.Value = %q, want %q", tags[0].Value, "1234567890") + } +} + +func TestGetTypeSize(t *testing.T) { + tests := []struct { + typeVal 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.typeVal) + if got != tt.want { + t.Errorf("getTypeSize(%d) = %d, want %d", tt.typeVal, got, tt.want) + } + } +} + +func TestGetTypeName(t *testing.T) { + tests := []struct { + typeVal 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.typeVal) + if got != tt.want { + t.Errorf("getTypeName(%d) = %q, want %q", tt.typeVal, got, tt.want) + } + } +} diff --git a/internal/parser/tiff/makernote/fujifilm/lookup.go b/internal/parser/tiff/makernote/fujifilm/lookup.go new file mode 100644 index 0000000..c259446 --- /dev/null +++ b/internal/parser/tiff/makernote/fujifilm/lookup.go @@ -0,0 +1,103 @@ +package fujifilm + +// fujifilmTagNames maps Fujifilm MakerNote tag IDs to human-readable names. +// Based on ExifTool Fujifilm tag documentation. +var fujifilmTagNames = map[uint16]string{ + // Version and basic info + 0x0000: "Version", + 0x0010: "SerialNumber", + 0x1000: "Quality", + 0x1001: "Sharpness", + 0x1002: "WhiteBalance", + 0x1003: "Saturation", + 0x1004: "Contrast", + 0x1005: "ColorTemperature", + 0x1006: "Contrast2", + 0x100a: "WhiteBalanceFineTune", + 0x100b: "NoiseReduction", + 0x100e: "HighISONoiseReduction", + + // Focus settings + 0x1010: "FlashMode", + 0x1011: "FlashExposureComp", + 0x1020: "Macro", + 0x1021: "FocusMode", + 0x1022: "AFMode", + 0x1023: "FocusPixel", + 0x102b: "PrioritySettings", + 0x102d: "FocusSettings", + 0x102e: "AFCSettings", + 0x1030: "SlowSync", + 0x1031: "PictureMode", + 0x1032: "ExposureCount", + 0x1033: "EXRAuto", + 0x1034: "EXRMode", + + // Image processing + 0x1040: "ShadowTone", + 0x1041: "HighlightTone", + 0x1044: "DigitalZoom", + 0x1045: "LensModulationOptimizer", + 0x1047: "GrainEffect", + 0x1048: "ColorChromeEffect", + 0x1049: "BWAdjustment", + 0x104d: "CropMode", + 0x104e: "ColorChromeFXBlue", + + // Lens info + 0x1050: "ShutterType", + 0x1100: "AutoBracketing", + 0x1101: "SequenceNumber", + 0x1103: "DriveSettings", + 0x1105: "PixelShiftShots", + 0x1106: "PixelShiftOffset", + + // Advanced settings + 0x1153: "PanoramaAngle", + 0x1154: "PanoramaDirection", + 0x1201: "AdvancedFilter", + + // Color and tone + 0x1210: "ColorMode", + 0x1300: "BlurWarning", + 0x1301: "FocusWarning", + 0x1302: "ExposureWarning", + 0x1304: "GEImageSize", + 0x1400: "DynamicRange", + 0x1401: "FilmMode", + 0x1402: "DynamicRangeSetting", + 0x1403: "DevelopmentDynamicRange", + 0x1404: "MinFocalLength", + 0x1405: "MaxFocalLength", + 0x1406: "MaxApertureAtMinFocal", + 0x1407: "MaxApertureAtMaxFocal", + 0x1408: "AutoDynamicRange", + 0x1409: "ImageStabilization", + 0x140b: "SceneRecognition", + 0x1422: "Rating", + 0x1425: "ImageCount", + 0x1431: "FlickerReduction", + 0x1436: "VideoRecordingMode", + 0x1438: "PeripheralLighting", + 0x1439: "VideoCompression", + 0x1443: "DRangePriority", + 0x1444: "DRangePriorityAuto", + 0x1445: "DRangePriorityFixed", + 0x1446: "FlickerReductionLevel", + + // Face detection + 0x4100: "FacesDetected", + 0x4103: "FacePositions", + 0x4200: "NumFaceElements", + 0x4201: "FaceElementTypes", + 0x4203: "FaceElementPositions", + 0x4282: "FaceRecInfo", + + // RAW development + 0x8000: "FileSource", + 0x8002: "OrderNumber", + 0x8003: "FrameNumber", + + // Additional tags + 0xb211: "Parallax", +} diff --git a/internal/parser/tiff/tiff.go b/internal/parser/tiff/tiff.go index c89b347..775b39e 100644 --- a/internal/parser/tiff/tiff.go +++ b/internal/parser/tiff/tiff.go @@ -10,6 +10,8 @@ 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/tiff/makernote/fujifilm" "github.com/gomantics/imx/internal/parser/tiff/makernote/sony" "github.com/gomantics/imx/internal/parser/xmp" ) @@ -44,8 +46,10 @@ type Parser struct { func New() *Parser { registry := makernote.NewRegistry() // Register MakerNote handlers in priority order (most specific first) - // Sony must be registered before Canon (Canon has no header, is fallback) + // Canon must be last (has no header, is fallback detection) registry.Register(sony.New()) + registry.Register(fujifilm.New()) + registry.Register(canon.New()) // Must be last - no header, fallback return &Parser{ icc: icc.New(), diff --git a/internal/testing/assert.go b/internal/testing/assert.go index f9f321b..b138ff5 100644 --- a/internal/testing/assert.go +++ b/internal/testing/assert.go @@ -65,16 +65,18 @@ func AssertDirectories(dirs []parser.Directory, expected []DirectoryExpectation) continue } - // Check tag count - if len(gotDir.Tags) != wantDir.ExactTagCount { + // Check tag count (skip if ExactTagCount is -1) + if wantDir.ExactTagCount >= 0 && len(gotDir.Tags) != wantDir.ExactTagCount { result.AddErrorf("Directory."+wantDir.Name, "has %d tags, want exactly %d", len(gotDir.Tags), wantDir.ExactTagCount) } - // Validate tags - tagResult := AssertTags(gotDir.Tags, wantDir.Tags) - for _, err := range tagResult.Errors { - result.AddError("Directory."+wantDir.Name+"."+err.Field, err.Message) + // Validate tags (skip if ExactTagCount is -1 and no tags specified - presence check only) + if !(wantDir.ExactTagCount == -1 && len(wantDir.Tags) == 0) { + tagResult := AssertTags(gotDir.Tags, wantDir.Tags) + for _, err := range tagResult.Errors { + result.AddError("Directory."+wantDir.Name+"."+err.Field, err.Message) + } } } diff --git a/internal/testing/expectations.go b/internal/testing/expectations.go index f73c7ba..959fba7 100644 --- a/internal/testing/expectations.go +++ b/internal/testing/expectations.go @@ -3,8 +3,8 @@ package testing // DirectoryExpectation defines expected directory with all its tags. type DirectoryExpectation struct { Name string // Directory name (e.g., "IFD0", "ExifIFD") - ExactTagCount int // MUST have exactly this many tags (catches missing/extra tags) - Tags []TagExpectation // ALL tags in this directory + ExactTagCount int // MUST have exactly this many tags (catches missing/extra tags). Use -1 to skip tag count validation. + Tags []TagExpectation // ALL tags in this directory. Leave empty with ExactTagCount -1 to just check presence. } // TagExpectation defines tag validation requirements.