diff --git a/internal/parser/tiff/makernote/nikon/lookup.go b/internal/parser/tiff/makernote/nikon/lookup.go new file mode 100644 index 0000000..8de771e --- /dev/null +++ b/internal/parser/tiff/makernote/nikon/lookup.go @@ -0,0 +1,113 @@ +package nikon + +// nikonTagNames maps Nikon MakerNote tag IDs to human-readable names. +// Based on ExifTool Nikon tag documentation. +var nikonTagNames = map[uint16]string{ + // Basic info (common across Nikon cameras) + 0x0001: "MakerNoteVersion", + 0x0002: "ISO", + 0x0003: "ColorMode", + 0x0004: "Quality", + 0x0005: "WhiteBalance", + 0x0006: "Sharpness", + 0x0007: "FocusMode", + 0x0008: "FlashSetting", + 0x0009: "FlashType", + 0x000B: "WhiteBalanceFineTune", + 0x000C: "WB_RBLevels", + 0x000D: "ProgramShift", + 0x000E: "ExposureDifference", + 0x000F: "ISOSelection", + 0x0010: "DataDump", + 0x0011: "PreviewIFD", + 0x0012: "FlashExposureComp", + 0x0013: "ISOSetting", + 0x0014: "ColorBalanceA", + 0x0016: "ImageBoundary", + 0x0017: "ExternalFlashExposureComp", + 0x0018: "FlashExposureBracketValue", + 0x0019: "ExposureBracketValue", + 0x001A: "ImageProcessing", + 0x001B: "CropHighSpeed", + 0x001C: "ExposureTuning", + 0x001D: "SerialNumber", + 0x001E: "ColorSpace", + 0x001F: "VRInfo", + 0x0020: "ImageAuthentication", + 0x0021: "FaceDetect", + 0x0022: "ActiveD-Lighting", + 0x0023: "PictureControlData", + 0x0024: "WorldTime", + 0x0025: "ISOInfo", + 0x002A: "VignetteControl", + 0x002B: "DistortInfo", + 0x002C: "UnknownInfo", + + // Lens and focus + 0x0080: "ImageAdjustment", + 0x0081: "ToneComp", + 0x0082: "AuxiliaryLens", + 0x0083: "LensType", + 0x0084: "Lens", + 0x0085: "ManualFocusDistance", + 0x0086: "DigitalZoom", + 0x0087: "FlashMode", + 0x0088: "AFInfo", + 0x0089: "ShootingMode", + 0x008A: "AutoBracketRelease", + 0x008B: "LensFStops", + 0x008C: "ContrastCurve", + 0x008D: "ColorHue", + 0x008F: "SceneMode", + 0x0090: "LightSource", + 0x0091: "ShotInfo", + 0x0092: "HueAdjustment", + 0x0093: "NEFCompression", + 0x0094: "Saturation", + 0x0095: "NoiseReduction", + 0x0096: "LinearizationTable", + 0x0097: "ColorBalance", + 0x0098: "LensData", // Encrypted + 0x0099: "RawImageCenter", + 0x009A: "SensorPixelSize", + 0x009C: "SceneAssist", + 0x009E: "RetouchHistory", + 0x00A0: "SerialNumber", + 0x00A2: "ImageDataSize", + 0x00A5: "ImageCount", + 0x00A6: "DeletedImageCount", + 0x00A7: "ShutterCount", + 0x00A8: "FlashInfo", // Encrypted + 0x00A9: "ImageOptimization", + 0x00AA: "Saturation", + 0x00AB: "VariProgram", + 0x00AC: "ImageStabilization", + 0x00AD: "AFResponse", + 0x00B0: "MultiExposure", + 0x00B1: "HighISONoiseReduction", + 0x00B3: "ToningEffect", + 0x00B6: "PowerUpTime", + 0x00B7: "AFInfo2", + 0x00B8: "FileInfo", + 0x00B9: "AFTune", + 0x00BB: "RetouchInfo", + 0x00BD: "PictureControlData", + 0x00C3: "BarometerInfo", + + // Preview + 0x0100: "DigitalICE", + 0x0103: "PreviewCompression", + 0x0201: "PreviewImageStart", + 0x0202: "PreviewImageLength", + + // Capture and scene info + 0x0E00: "PrintIM", + 0x0E01: "NikonCaptureData", + 0x0E09: "NikonCaptureVersion", + 0x0E0E: "NikonCaptureOffsets", + 0x0E10: "NikonScanIFD", + 0x0E13: "NikonCaptureEditVersions", + 0x0E1D: "NikonICCProfile", + 0x0E1E: "NikonCaptureOutput", + 0x0E22: "NEFBitDepth", +} diff --git a/internal/parser/tiff/makernote/nikon/nikon.go b/internal/parser/tiff/makernote/nikon/nikon.go new file mode 100644 index 0000000..e10b539 --- /dev/null +++ b/internal/parser/tiff/makernote/nikon/nikon.go @@ -0,0 +1,431 @@ +// Package nikon implements Nikon MakerNote parsing. +// +// Nikon MakerNote has multiple variants: +// +// Type 1 (older cameras like D1, D100): +// - Header: 'Nikon\x00\x01\x00' (8 bytes) +// - IFD starts at offset 8 +// - Offsets are absolute (relative to EXIF TIFF header) +// - Inherits byte order from parent TIFF +// +// Type 3 (modern cameras like D3, D700, D800, Z series): +// - Header: 'Nikon\x00\x02\x00\x00' (10 bytes) + embedded TIFF header +// - Embedded TIFF starts at offset 10 with own byte order marker +// - IFD offset is read from embedded TIFF header +// - Offsets are relative to MakerNote start (offset 10) +// +// Encrypted tags (0x0098 LensData, 0x00A8 FlashInfo) are skipped. +package nikon + +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 Nikon cameras. +type Handler struct{} + +// New creates a new Nikon MakerNote handler. +func New() *Handler { + return &Handler{} +} + +// Manufacturer returns "Nikon". +func (h *Handler) Manufacturer() string { + return "Nikon" +} + +// Detect checks if the data is a Nikon MakerNote. +// It tries Type 3 first (more common in modern cameras), then Type 1. +func (h *Handler) Detect(data []byte) (bool, *makernote.Config) { + // Try Type 3 first (most common) + if ok, cfg := makernote.DetectNikonType3(data); ok { + return ok, cfg + } + // Try Type 1 + return makernote.DetectNikonType1(data) +} + +// Parse extracts metadata from Nikon MakerNote. +func (h *Handler) Parse(r io.ReaderAt, makerNoteOffset, exifBase int64, cfg *makernote.Config) ([]parser.Tag, *parser.ParseError) { + parseErr := parser.NewParseError() + + // Get byte order - may be nil for Type 1 (inherit from parent) + order := cfg.ByteOrder + if order == nil { + // Try to detect from file header + header := make([]byte, 2) + _, err := r.ReadAt(header, 0) + if err != nil { + parseErr.Add(fmt.Errorf("failed to read TIFF header for byte order: %w", err)) + return nil, parseErr + } + if header[0] == 'I' && header[1] == 'I' { + order = binary.LittleEndian + } else { + order = binary.BigEndian + } + } + + reader := imxbin.NewReader(r, order) + + // Calculate IFD start position based on variant + var ifdOffset int64 + if cfg.Variant == "Type3" { + // Type 3: IFD offset is stored in embedded TIFF header at offset 14-17 + // (MakerNote offset + 10 byte header + 4 byte for byte order + magic) + tiffBase := makerNoteOffset + 10 + offsetVal, err := reader.ReadUint32(tiffBase + 4) + if err != nil { + parseErr.Add(fmt.Errorf("failed to read Type3 IFD offset: %w", err)) + return nil, parseErr + } + ifdOffset = tiffBase + int64(offsetVal) + } else { + // Type 1: IFD starts at offset 8 + ifdOffset = makerNoteOffset + cfg.IFDOffset + } + + // Read number of IFD entries + numEntries, err := reader.ReadUint16(ifdOffset) + if err != nil { + parseErr.Add(fmt.Errorf("failed to read Nikon IFD entry count: %w", err)) + return nil, parseErr + } + + // Sanity check entry count + if numEntries == 0 || numEntries > 200 { + parseErr.Add(fmt.Errorf("invalid Nikon 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 Nikon 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.OrNil() +} + +// 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 + } + + // Skip encrypted tags (LensData, FlashInfo, etc.) + if isEncryptedTag(tagID) { + return nil, nil + } + + // 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 interface{} + if totalSize <= 4 { + // Value is inline + value, err = h.readInlineValue(r, valueOffset, typeVal, count) + } else { + // Value is at offset + 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("Nikon:0x%04X", tagID)), + Name: tagName, + Value: value, + DataType: getTypeName(typeVal), + }, nil +} + +// isEncryptedTag returns true if the tag contains encrypted data. +func isEncryptedTag(tagID uint16) bool { + switch tagID { + case 0x0098: // LensData + return true + case 0x00A8: // FlashInfo + return true + default: + return false + } +} + +// resolveOffset calculates the absolute file offset for a tag value. +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: + // Type 3: offsets are relative to the TIFF header within MakerNote (offset 10) + return makerNoteOffset + 10 + 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) (interface{}, 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 + if count > 0 { + return string(buf[:count-1]), nil // Exclude null terminator + } + return "", nil + 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) (interface{}, 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 Nikon tag. +func (h *Handler) TagName(tagID uint16) string { + if name, ok := nikonTagNames[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/nikon/nikon_test.go b/internal/parser/tiff/makernote/nikon/nikon_test.go new file mode 100644 index 0000000..3926683 --- /dev/null +++ b/internal/parser/tiff/makernote/nikon/nikon_test.go @@ -0,0 +1,387 @@ +package nikon + +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 != "Nikon" { + t.Errorf("Manufacturer() = %v, want Nikon", got) + } +} + +func TestHandler_Detect_Type3(t *testing.T) { + tests := []struct { + name string + data []byte + wantOK bool + wantOffset int64 + wantOrder binary.ByteOrder + }{ + { + name: "Valid Nikon Type 3 LE", + data: func() []byte { + // Nikon\x00\x02\x00\x00 + II + 0x002a + IFD offset + buf := make([]byte, 26) + copy(buf[0:5], "Nikon") + buf[5] = 0x00 + buf[6] = 0x02 + buf[7] = 0x00 + buf[8] = 0x00 + buf[9] = 0x00 + buf[10] = 'I' + buf[11] = 'I' + binary.LittleEndian.PutUint16(buf[12:14], 0x002a) // TIFF magic + binary.LittleEndian.PutUint32(buf[14:18], 8) // IFD offset + // Minimal IFD + binary.LittleEndian.PutUint16(buf[18:20], 1) // entry count + return buf + }(), + wantOK: true, + wantOffset: 18, + wantOrder: binary.LittleEndian, + }, + { + name: "Valid Nikon Type 3 BE", + data: func() []byte { + buf := make([]byte, 26) + copy(buf[0:5], "Nikon") + buf[5] = 0x00 + buf[6] = 0x02 + buf[7] = 0x00 + buf[8] = 0x00 + buf[9] = 0x00 + buf[10] = 'M' + buf[11] = 'M' + binary.BigEndian.PutUint16(buf[12:14], 0x002a) + binary.BigEndian.PutUint32(buf[14:18], 8) + binary.BigEndian.PutUint16(buf[18:20], 1) + return buf + }(), + wantOK: true, + wantOffset: 18, + wantOrder: binary.BigEndian, + }, + { + name: "Too short", + data: []byte("Nikon\x00\x02"), + wantOK: false, + }, + { + name: "Wrong magic byte (Type 1 format)", + data: func() []byte { + buf := make([]byte, 18) + copy(buf[0:5], "Nikon") + buf[5] = 0x00 + buf[6] = 0x01 // Type 1, not Type 3 + return buf + }(), + wantOK: false, + }, + { + name: "Invalid byte order", + data: func() []byte { + buf := make([]byte, 18) + copy(buf[0:5], "Nikon") + buf[5] = 0x00 + buf[6] = 0x02 + buf[7] = 0x00 + buf[8] = 0x00 + buf[9] = 0x00 + buf[10] = 'X' // Invalid + buf[11] = 'X' + return buf + }(), + wantOK: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ok, cfg := makernote.DetectNikonType3(tt.data) + if ok != tt.wantOK { + t.Errorf("DetectNikonType3() ok = %v, want %v", ok, tt.wantOK) + } + if ok { + if cfg.IFDOffset != tt.wantOffset { + t.Errorf("IFDOffset = %d, want %d", cfg.IFDOffset, tt.wantOffset) + } + if cfg.ByteOrder != tt.wantOrder { + t.Errorf("ByteOrder = %v, want %v", cfg.ByteOrder, tt.wantOrder) + } + if cfg.OffsetBase != makernote.OffsetRelativeToMakerNote { + t.Errorf("OffsetBase = %v, want OffsetRelativeToMakerNote", cfg.OffsetBase) + } + if cfg.Variant != "Type3" { + t.Errorf("Variant = %s, want Type3", cfg.Variant) + } + } + }) + } +} + +func TestHandler_Detect_Type1(t *testing.T) { + tests := []struct { + name string + data []byte + wantOK bool + wantOffset int64 + }{ + { + name: "Valid Nikon Type 1", + data: func() []byte { + buf := make([]byte, 20) + copy(buf[0:5], "Nikon") + buf[5] = 0x00 + buf[6] = 0x01 + buf[7] = 0x00 + // IFD starts at offset 8 + binary.LittleEndian.PutUint16(buf[8:10], 1) // entry count + return buf + }(), + wantOK: true, + wantOffset: 8, + }, + { + name: "Too short", + data: []byte("Nikon\x00\x01"), + wantOK: false, + }, + { + name: "Not Nikon", + data: func() []byte { + buf := make([]byte, 12) + copy(buf[0:5], "Canon") + return buf + }(), + wantOK: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ok, cfg := makernote.DetectNikonType1(tt.data) + if ok != tt.wantOK { + t.Errorf("DetectNikonType1() ok = %v, want %v", ok, tt.wantOK) + } + if ok { + if cfg.IFDOffset != tt.wantOffset { + t.Errorf("IFDOffset = %d, want %d", cfg.IFDOffset, tt.wantOffset) + } + if cfg.OffsetBase != makernote.OffsetAbsolute { + t.Errorf("OffsetBase = %v, want OffsetAbsolute", cfg.OffsetBase) + } + if cfg.ByteOrder != nil { + t.Errorf("ByteOrder = %v, want nil (inherit)", cfg.ByteOrder) + } + if cfg.Variant != "Type1" { + t.Errorf("Variant = %s, want Type1", cfg.Variant) + } + } + }) + } +} + +func TestHandler_Detect_Combined(t *testing.T) { + h := New() + + // Type 3 should be detected + type3Data := make([]byte, 26) + copy(type3Data[0:5], "Nikon") + type3Data[5] = 0x00 + type3Data[6] = 0x02 + type3Data[10] = 'I' + type3Data[11] = 'I' + binary.LittleEndian.PutUint16(type3Data[12:14], 0x002a) + binary.LittleEndian.PutUint32(type3Data[14:18], 8) + + ok, cfg := h.Detect(type3Data) + if !ok { + t.Error("Handler.Detect() should detect Type 3") + } + if cfg.Variant != "Type3" { + t.Errorf("Variant = %s, want Type3", cfg.Variant) + } + + // Type 1 should be detected + type1Data := make([]byte, 20) + copy(type1Data[0:5], "Nikon") + type1Data[5] = 0x00 + type1Data[6] = 0x01 + type1Data[7] = 0x00 + + ok, cfg = h.Detect(type1Data) + if !ok { + t.Error("Handler.Detect() should detect Type 1") + } + if cfg.Variant != "Type1" { + t.Errorf("Variant = %s, want Type1", cfg.Variant) + } + + // Sony should not be detected + sonyData := []byte("SONY DSC \x00\x00\x00") + ok, _ = h.Detect(sonyData) + if ok { + t.Error("Handler.Detect() should not detect Sony data") + } +} + +func TestHandler_Parse_Type1(t *testing.T) { + // Build a test Type 1 MakerNote + data := buildTestType1MakerNote() + + h := New() + cfg := &makernote.Config{ + IFDOffset: 8, + OffsetBase: makernote.OffsetAbsolute, + ByteOrder: binary.LittleEndian, + HasNextIFD: false, + Variant: "Type1", + } + + tags, parseErr := h.Parse(bytes.NewReader(data), 0, 0, cfg) + if parseErr != nil { + t.Fatalf("Parse() error = %v", parseErr) + } + + if tags == nil { + t.Fatal("Parse() returned nil tags") + } + + if len(tags) != 3 { + t.Errorf("Got %d tags, want 3", len(tags)) + } + + // Check specific tags + tagMap := make(map[string]interface{}) + for _, tag := range tags { + tagMap[tag.Name] = tag.Value + } + + if _, ok := tagMap["MakerNoteVersion"]; !ok { + t.Error("Missing MakerNoteVersion tag") + } + + if _, ok := tagMap["SerialNumber"]; !ok { + t.Error("Missing SerialNumber tag") + } +} + +func TestHandler_TagName(t *testing.T) { + h := New() + + tests := []struct { + tagID uint16 + want string + }{ + {0x0001, "MakerNoteVersion"}, + {0x0002, "ISO"}, + {0x001D, "SerialNumber"}, + {0x0083, "LensType"}, + {0x0084, "Lens"}, + {0xFFFF, ""}, // Unknown tag returns empty string + } + + for _, tt := range tests { + t.Run(tt.want, func(t *testing.T) { + got := h.TagName(tt.tagID) + if got != tt.want { + t.Errorf("TagName(0x%04X) = %s, want %s", tt.tagID, got, tt.want) + } + }) + } +} + +func TestHandler_EncryptedTags(t *testing.T) { + // Verify encrypted tags are skipped + if !isEncryptedTag(0x0098) { + t.Error("0x0098 (LensData) should be encrypted") + } + if !isEncryptedTag(0x00A8) { + t.Error("0x00A8 (FlashInfo) should be encrypted") + } + if isEncryptedTag(0x0001) { + t.Error("0x0001 should not be encrypted") + } +} + +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 { + if got := getTypeSize(tt.typeVal); got != tt.want { + t.Errorf("getTypeSize(%d) = %d, want %d", tt.typeVal, got, tt.want) + } + } +} + +// buildTestType1MakerNote creates a test Nikon Type 1 MakerNote with 3 entries. +func buildTestType1MakerNote() []byte { + // Layout: + // 0-7: Nikon\x00\x01\x00 header + // 8-9: entry count (3) + // 10-21: entry 1 (MakerNoteVersion - UNDEFINED) + // 22-33: entry 2 (SerialNumber - ASCII) + // 34-45: entry 3 (Quality - SHORT) + // 46-49: next IFD offset (0) + // 50+: string data + + buf := make([]byte, 100) + + // Nikon Type 1 header + copy(buf[0:5], "Nikon") + buf[5] = 0x00 + buf[6] = 0x01 + buf[7] = 0x00 + + // Entry count: 3 + binary.LittleEndian.PutUint16(buf[8:10], 3) + + // Entry 1: MakerNoteVersion (0x0001), UNDEFINED, 4 bytes inline + binary.LittleEndian.PutUint16(buf[10:12], 0x0001) + binary.LittleEndian.PutUint16(buf[12:14], 7) // UNDEFINED + binary.LittleEndian.PutUint32(buf[14:18], 4) + copy(buf[18:22], "0210") // Version inline + + // Entry 2: SerialNumber (0x001D), ASCII, 10 bytes at offset 50 + binary.LittleEndian.PutUint16(buf[22:24], 0x001D) + binary.LittleEndian.PutUint16(buf[24:26], 2) // ASCII + binary.LittleEndian.PutUint32(buf[26:30], 10) + binary.LittleEndian.PutUint32(buf[30:34], 50) + + // Entry 3: Quality (0x0004), SHORT, inline + binary.LittleEndian.PutUint16(buf[34:36], 0x0004) + binary.LittleEndian.PutUint16(buf[36:38], 3) // SHORT + binary.LittleEndian.PutUint32(buf[38:42], 1) + binary.LittleEndian.PutUint16(buf[42:44], 1) // Value: 1 (Fine) + + // Next IFD offset: 0 + binary.LittleEndian.PutUint32(buf[46:50], 0) + + // String data + copy(buf[50:60], "12345678\x00\x00") + + return buf +} diff --git a/internal/parser/tiff/tiff.go b/internal/parser/tiff/tiff.go index 775b39e..66990b7 100644 --- a/internal/parser/tiff/tiff.go +++ b/internal/parser/tiff/tiff.go @@ -12,6 +12,7 @@ import ( "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/nikon" "github.com/gomantics/imx/internal/parser/tiff/makernote/sony" "github.com/gomantics/imx/internal/parser/xmp" ) @@ -47,6 +48,7 @@ func New() *Parser { registry := makernote.NewRegistry() // Register MakerNote handlers in priority order (most specific first) // Canon must be last (has no header, is fallback detection) + registry.Register(nikon.New()) registry.Register(sony.New()) registry.Register(fujifilm.New()) registry.Register(canon.New()) // Must be last - no header, fallback