diff --git a/internal/parser/tiff/makernote/sony/lookup.go b/internal/parser/tiff/makernote/sony/lookup.go new file mode 100644 index 0000000..74a67c6 --- /dev/null +++ b/internal/parser/tiff/makernote/sony/lookup.go @@ -0,0 +1,89 @@ +package sony + +// sonyTagNames maps Sony MakerNote tag IDs to human-readable names. +// Based on ExifTool Sony tag documentation. +var sonyTagNames = map[uint16]string{ + // Basic camera info + 0x0102: "Quality", + 0x0104: "FlashExposureComp", + 0x0105: "Teleconverter", + 0x0112: "WhiteBalanceFineTune", + 0x0114: "CameraSettings", + 0x0115: "WhiteBalance", + 0x0116: "ExtraInfo", + 0x0e00: "PrintIM", + + // Image settings + 0x1000: "MultiBurstMode", + 0x1001: "MultiBurstImageWidth", + 0x1002: "MultiBurstImageHeight", + 0x1003: "Panorama", + + // Focus info + 0x2001: "PreviewImage", + 0x2002: "Rating", + 0x2004: "Contrast", + 0x2005: "Saturation", + 0x2006: "Sharpness", + 0x2007: "Brightness", + 0x2008: "LongExposureNoiseReduction", + 0x2009: "HighISONoiseReduction", + 0x200a: "HDR", + 0x200b: "MultiFrameNoiseReduction", + 0x200e: "PictureEffect", + 0x200f: "SoftSkinEffect", + 0x2010: "VignettingCorrection", + 0x2011: "LateralChromaticAberration", + 0x2012: "DistortionCorrection", + 0x2013: "WBShiftAB_GM", + 0x2014: "AutoPortraitFramed", + 0x2016: "FaceInfo", + 0x201a: "ElectronicFrontCurtainShutter", + 0x201b: "FocusMode", + 0x201c: "AFAreaModeSetting", + 0x201d: "FlexibleSpotPosition", + 0x201e: "AFPointSelected", + 0x2020: "AFPointsUsed", + 0x2021: "FocalPlaneAFPointsUsed", + 0x2022: "MultiFrameNREffect", + 0x2023: "ShotInfo", + 0x2027: "AFMicroAdj", + 0x2028: "ExposureProgram", + 0x2029: "WBShiftAB_GM_Precise", + 0x3000: "ShotInfo", + + // Serial and model + 0x2031: "SerialNumber", + + // Sony-specific + 0xb000: "FileFormat", + 0xb001: "SonyModelID", + 0xb020: "CreativeStyle", + 0xb021: "ColorTemperature", + 0xb022: "ColorCompensationFilter", + 0xb023: "SceneMode", + 0xb024: "ZoneMatching", + 0xb025: "DynamicRangeOptimizer", + 0xb026: "ImageStabilization", + 0xb027: "LensID", + 0xb028: "MinoltaMakerNote", + 0xb029: "ColorMode", + 0xb02a: "LensSpec", + 0xb02b: "FullImageSize", + 0xb02c: "PreviewImageSize", + 0xb040: "Macro", + 0xb041: "ExposureMode", + 0xb042: "FocusMode", + 0xb043: "AFMode", + 0xb044: "AFIlluminator", + 0xb047: "Quality", + 0xb048: "FlashLevel", + 0xb049: "ReleaseMode", + 0xb04a: "SequenceNumber", + 0xb04b: "AntiBlur", + 0xb04e: "LongExposureNoiseReduction", + 0xb04f: "DynamicRangeOptimizer", + 0xb050: "HighISONoiseReduction", + 0xb052: "IntelligentAuto", + 0xb054: "WhiteBalance", +} diff --git a/internal/parser/tiff/makernote/sony/sony.go b/internal/parser/tiff/makernote/sony/sony.go new file mode 100644 index 0000000..102fe4a --- /dev/null +++ b/internal/parser/tiff/makernote/sony/sony.go @@ -0,0 +1,371 @@ +// Package sony implements Sony MakerNote parsing. +// +// Sony MakerNote format: +// - Header: 'SONY DSC ' or 'SONY CAM ' (12 bytes) +// - IFD starts at offset 12 +// - Offsets are absolute (relative to EXIF TIFF header) +// - Always little-endian +// - No next-IFD pointer +// +// Some older Sony/Minolta cameras have headerless MakerNotes. +package sony + +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 Sony cameras. +type Handler struct{} + +// New creates a new Sony MakerNote handler. +func New() *Handler { + return &Handler{} +} + +// Manufacturer returns "Sony". +func (h *Handler) Manufacturer() string { + return "Sony" +} + +// Detect checks if the data is a Sony MakerNote. +func (h *Handler) Detect(data []byte) (bool, *makernote.Config) { + return makernote.DetectSony(data) +} + +// Parse extracts metadata from Sony MakerNote. +func (h *Handler) Parse(r io.ReaderAt, makerNoteOffset, exifBase int64, cfg *makernote.Config) ([]parser.Tag, *parser.ParseError) { + parseErr := parser.NewParseError() + + // Create reader with configured byte order + order := cfg.ByteOrder + if order == nil { + order = binary.LittleEndian // Sony is always LE + } + 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 Sony IFD entry count: %w", err)) + return nil, parseErr + } + + // Sanity check entry count + if numEntries == 0 || numEntries > 200 { + parseErr.Add(fmt.Errorf("invalid Sony 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 Sony 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 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("Sony:0x%04X", tagID)), + Name: tagName, + Value: value, + DataType: getTypeName(typeVal), + }, nil +} + +// 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: + 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) (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 + 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) (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 Sony tag. +func (h *Handler) TagName(tagID uint16) string { + if name, ok := sonyTagNames[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/sony/sony_test.go b/internal/parser/tiff/makernote/sony/sony_test.go new file mode 100644 index 0000000..bef86be --- /dev/null +++ b/internal/parser/tiff/makernote/sony/sony_test.go @@ -0,0 +1,286 @@ +package sony + +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 != "Sony" { + t.Errorf("Manufacturer() = %q, want %q", got, "Sony") + } +} + +func TestHandler_Detect(t *testing.T) { + h := New() + + tests := []struct { + name string + data []byte + wantOK bool + wantCfg *makernote.Config + }{ + { + name: "SONY DSC header", + data: append([]byte("SONY DSC "), make([]byte, 100)...), + wantOK: true, + wantCfg: &makernote.Config{ + IFDOffset: 12, + OffsetBase: makernote.OffsetAbsolute, + ByteOrder: binary.LittleEndian, + HasNextIFD: false, + Variant: "Standard", + }, + }, + { + name: "SONY CAM header", + data: append([]byte("SONY CAM "), make([]byte, 100)...), + wantOK: true, + wantCfg: &makernote.Config{ + IFDOffset: 12, + OffsetBase: makernote.OffsetAbsolute, + ByteOrder: binary.LittleEndian, + HasNextIFD: false, + Variant: "Standard", + }, + }, + { + name: "not Sony - Canon", + data: []byte{0x05, 0x00, 0x01, 0x00, 0x02, 0x00}, // Looks like Canon IFD + wantOK: false, + }, + { + name: "not Sony - Nikon", + data: []byte("Nikon\x00\x02\x00"), + wantOK: false, + }, + { + name: "too short", + data: []byte("SONY"), + 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 + }{ + {0x0102, "Quality"}, + {0x0115, "WhiteBalance"}, + {0x2031, "SerialNumber"}, + {0xb001, "SonyModelID"}, + {0xb027, "LensID"}, + {0xb020, "CreativeStyle"}, + {0x9999, ""}, // Unknown tag + } + + for _, tt := range tests { + t.Run(tt.expected, 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 Sony MakerNote with header and one IFD entry + // Header: "SONY DSC " (12 bytes) + // Entry count: 1 (2 bytes) + // Entry: tag=0xb001, type=SHORT(3), count=1, value=123 + data := make([]byte, 100) + copy(data[0:12], []byte("SONY DSC ")) + + // IFD at offset 12 + binary.LittleEndian.PutUint16(data[12:14], 1) // 1 entry + + // Entry at offset 14 + binary.LittleEndian.PutUint16(data[14:16], 0xb001) // Tag: SonyModelID + binary.LittleEndian.PutUint16(data[16:18], 3) // Type: SHORT + binary.LittleEndian.PutUint32(data[18:22], 1) // Count: 1 + binary.LittleEndian.PutUint32(data[22:26], 123) // Value: 123 (inline) + + reader := bytes.NewReader(data) + cfg := &makernote.Config{ + IFDOffset: 12, + OffsetBase: makernote.OffsetAbsolute, + 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 != "SonyModelID" { + t.Errorf("Tag.Name = %q, want %q", tag.Name, "SonyModelID") + } + if tag.ID != "Sony:0xB001" { + t.Errorf("Tag.ID = %q, want %q", tag.ID, "Sony:0xB001") + } + if val, ok := tag.Value.(uint16); !ok || val != 123 { + t.Errorf("Tag.Value = %v (%T), want 123 (uint16)", tag.Value, tag.Value) + } +} + +func TestHandler_Parse_MultipleEntries(t *testing.T) { + h := New() + + // Build Sony MakerNote with 3 entries + data := make([]byte, 200) + copy(data[0:12], []byte("SONY DSC ")) + + // IFD at offset 12 + binary.LittleEndian.PutUint16(data[12:14], 3) // 3 entries + + // Entry 1: Quality (0x0102), SHORT, value=1 + binary.LittleEndian.PutUint16(data[14:16], 0x0102) + binary.LittleEndian.PutUint16(data[16:18], 3) + binary.LittleEndian.PutUint32(data[18:22], 1) + binary.LittleEndian.PutUint32(data[22:26], 1) + + // Entry 2: WhiteBalance (0x0115), SHORT, value=0 + binary.LittleEndian.PutUint16(data[26:28], 0x0115) + binary.LittleEndian.PutUint16(data[28:30], 3) + binary.LittleEndian.PutUint32(data[30:34], 1) + binary.LittleEndian.PutUint32(data[34:38], 0) + + // Entry 3: SonyModelID (0xb001), SHORT, value=456 + binary.LittleEndian.PutUint16(data[38:40], 0xb001) + binary.LittleEndian.PutUint16(data[40:42], 3) + binary.LittleEndian.PutUint32(data[42:46], 1) + binary.LittleEndian.PutUint32(data[46:50], 456) + + reader := bytes.NewReader(data) + cfg := &makernote.Config{ + IFDOffset: 12, + OffsetBase: makernote.OffsetAbsolute, + 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", 1}, + {"WhiteBalance", 0}, + {"SonyModelID", 456}, + } + + 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 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/tiff.go b/internal/parser/tiff/tiff.go index 58fc7d9..c89b347 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/sony" "github.com/gomantics/imx/internal/parser/xmp" ) @@ -41,11 +42,16 @@ type Parser struct { // New creates a new TIFF parser 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) + registry.Register(sony.New()) + return &Parser{ icc: icc.New(), iptc: iptc.New(), xmp: xmp.New(), - makernote: makernote.NewRegistry(), + makernote: registry, } }