Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions internal/meta/exif/exif.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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

Expand Down
10 changes: 5 additions & 5 deletions internal/meta/exif/exif_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down Expand Up @@ -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")
}
}

Expand All @@ -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")
}
}

Expand Down
57 changes: 36 additions & 21 deletions internal/meta/iptc/iptc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down
14 changes: 9 additions & 5 deletions internal/meta/iptc/iptc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}
}

Expand Down
Loading