diff --git a/ROADMAP.md b/ROADMAP.md index c7baa54..e8ebec8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -65,6 +65,11 @@ This document outlines the current status and future plans for imx. ### Potential Features +- **Human-Readable Value Conversions** - Convert raw EXIF values to human-readable formats + - APEX values (ShutterSpeedValue, ApertureValue, MaxApertureValue) → "1/50", "f/9.0", etc. + - Enum values (ResolutionUnit, ExposureProgram, MeteringMode, etc.) → "inches", "Aperture-priority AE", etc. + - GPS coordinates → Decimal degrees format + - Fraction values (ExposureTime, ExposureCompensation) → Formatted strings - **Maker Notes** - Decode manufacturer-specific data (Nikon, Canon, Sony, etc.) - **Thumbnail Extraction** - Extract embedded preview images - **Metadata Writing** - Modify and write metadata back to files diff --git a/internal/meta/exif/exif.go b/internal/meta/exif/exif.go index 6899791..ca6cc4c 100644 --- a/internal/meta/exif/exif.go +++ b/internal/meta/exif/exif.go @@ -79,7 +79,7 @@ func (p *Parser) parseTIFF(data []byte) ([]common.Directory, error) { dirs = append(dirs, ifd0) // Check for EXIF sub-IFD pointer - if exifOffset, ok := ifd0.Tags["EXIF:ExifOffset"]; ok { + if exifOffset, ok := ifd0.Tags["EXIF:IFD0:ExifOffset"]; ok { if offset, ok := exifOffset.Value.(int); ok && offset > 0 && offset < len(data) { exifIFD, _, err := p.parseIFD(data, offset, byteOrder, "ExifIFD") if err == nil { @@ -89,7 +89,7 @@ func (p *Parser) parseTIFF(data []byte) ([]common.Directory, error) { } // Check for GPS sub-IFD pointer - if gpsOffset, ok := ifd0.Tags["EXIF:GPSInfo"]; ok { + if gpsOffset, ok := ifd0.Tags["EXIF:IFD0:GPSInfo"]; ok { if offset, ok := gpsOffset.Value.(int); ok && offset > 0 && offset < len(data) { gpsIFD, _, err := p.parseIFD(data, offset, byteOrder, "GPS") if err == nil { @@ -179,6 +179,11 @@ func (p *Parser) parseEntry(data []byte, offset int, byteOrder binary.ByteOrder, if !ok { tagName = fmt.Sprintf("Tag%04X", tagID) } + + // Prefix all tags with IFD name for clarity and to avoid ambiguity + // e.g., IFD0:XResolution vs IFD1:XResolution + tagName = ifdName + ":" + tagName + tag.ID = common.TagID("EXIF:" + tagName) tag.Name = tagName diff --git a/internal/meta/exif/exif_test.go b/internal/meta/exif/exif_test.go index f880d9c..fc3ab9d 100644 --- a/internal/meta/exif/exif_test.go +++ b/internal/meta/exif/exif_test.go @@ -233,7 +233,7 @@ func TestParser_ParseWithTags(t *testing.T) { } // Check for Make tag - if tag, ok := dirs[0].Tags["EXIF:Make"]; ok { + if tag, ok := dirs[0].Tags["EXIF:IFD0:Make"]; ok { if tag.Value != "Canon" { t.Errorf("Make value = %v, want %q", tag.Value, "Canon") } @@ -757,8 +757,8 @@ func TestParser_ParseEntry_UnknownTag(t *testing.T) { tag := p.parseEntry(data, 0, byteOrder, "IFD0") - if tag.Name != "TagFFFF" { - t.Errorf("Unknown tag name = %q, want %q", tag.Name, "TagFFFF") + if tag.Name != "IFD0:TagFFFF" { + t.Errorf("Unknown tag name = %q, want %q", tag.Name, "IFD0:TagFFFF") } } @@ -775,8 +775,8 @@ func TestParser_ParseEntry_GPSTags(t *testing.T) { tag := p.parseEntry(data, 0, byteOrder, "GPS") - if tag.Name != "GPSLatitudeRef" { - t.Errorf("GPS tag name = %q, want %q", tag.Name, "GPSLatitudeRef") + if tag.Name != "GPS:GPSLatitudeRef" { + t.Errorf("GPS tag name = %q, want %q", tag.Name, "GPS:GPSLatitudeRef") } } diff --git a/internal/meta/iptc/iptc.go b/internal/meta/iptc/iptc.go index 07c2216..bde2d2d 100644 --- a/internal/meta/iptc/iptc.go +++ b/internal/meta/iptc/iptc.go @@ -75,38 +75,53 @@ func (p *Parser) buildDirectories(datasets []Dataset) []common.Directory { Tags: make(map[common.TagID]common.Tag), } - // Track repeatable fields - repeatCounts := make(map[uint8]int) + // Track values for repeatable fields + repeatableValues := make(map[string][]any) + repeatableRaws := make(map[string][][]byte) for _, ds := range recordDatasets { - // Build tag ID - var tagID common.TagID + tagID := common.TagID("IPTC:" + ds.Name) + if isRepeatable(ds.Record, ds.DatasetID) { - count := repeatCounts[ds.DatasetID] - repeatCounts[ds.DatasetID]++ - if count == 0 { - tagID = common.TagID("IPTC:" + ds.Name) - } else { - tagID = common.TagID(fmt.Sprintf("IPTC:%s[%d]", ds.Name, count)) - } + // Aggregate repeatable field values into arrays + repeatableValues[ds.Name] = append(repeatableValues[ds.Name], ds.Value) + repeatableRaws[ds.Name] = append(repeatableRaws[ds.Name], ds.Raw) } else { - tagID = common.TagID("IPTC:" + ds.Name) + // Non-repeatable field - create tag directly + dataType := "string" + switch ds.Value.(type) { + case int: + dataType = "int" + } + + dir.Tags[tagID] = common.Tag{ + Spec: common.SpecIPTC, + ID: tagID, + Name: ds.Name, + DataType: dataType, + Value: ds.Value, + Raw: ds.Raw, + } } + } - // Determine data type - dataType := "string" - switch ds.Value.(type) { - case int: - dataType = "int" + // Create tags for repeatable fields with aggregated values + for name, values := range repeatableValues { + tagID := common.TagID("IPTC:" + name) + var value any + if len(values) == 1 { + value = values[0] + } else { + value = values } dir.Tags[tagID] = common.Tag{ Spec: common.SpecIPTC, ID: tagID, - Name: ds.Name, - DataType: dataType, - Value: ds.Value, - Raw: ds.Raw, + Name: name, + DataType: "array", + Value: value, + Raw: repeatableRaws[name][0], // Use first raw value } } diff --git a/internal/meta/iptc/iptc_test.go b/internal/meta/iptc/iptc_test.go index 88b9156..3d4e071 100644 --- a/internal/meta/iptc/iptc_test.go +++ b/internal/meta/iptc/iptc_test.go @@ -90,12 +90,16 @@ func TestParser_Parse_ValidIPTC(t *testing.T) { if _, ok := dir.Tags["IPTC:Byline"]; !ok { t.Error("Missing IPTC:Byline tag") } - // Keywords should be indexed - if _, ok := dir.Tags["IPTC:Keywords"]; !ok { + // Keywords should be aggregated into an array + if tag, ok := dir.Tags["IPTC:Keywords"]; !ok { t.Error("Missing IPTC:Keywords tag") - } - if _, ok := dir.Tags["IPTC:Keywords[1]"]; !ok { - t.Error("Missing IPTC:Keywords[1] tag") + } else { + // Check that it's an array with 2 values + if arr, ok := tag.Value.([]any); !ok { + t.Errorf("Keywords value should be array, got %T", tag.Value) + } else if len(arr) != 2 { + t.Errorf("Keywords array should have 2 values, got %d", len(arr)) + } } }