diff --git a/assets/Display_P3.heic b/assets/Display_P3.heic new file mode 100644 index 0000000..e1319d3 Binary files /dev/null and b/assets/Display_P3.heic differ diff --git a/assets/My_Display_P3.heic b/assets/My_Display_P3.heic new file mode 100644 index 0000000..20d87c3 Binary files /dev/null and b/assets/My_Display_P3.heic differ diff --git a/common_test.go b/common_test.go index 214429a..9f843f6 100644 --- a/common_test.go +++ b/common_test.go @@ -8,7 +8,7 @@ import ( "io/ioutil" - "github.com/dsoprea/go-logging" + log "github.com/dsoprea/go-logging" ) var ( @@ -163,17 +163,10 @@ func init() { // This will only be executed when we're running tests in this package and // not when this package is being imported from a subpackage. - goPath := os.Getenv("GOPATH") - if goPath != "" { - assetsPath = path.Join(goPath, "src", "github.com", "dsoprea", "go-exif", "assets") - } else { - // Module-enabled context. + currentWd, err := os.Getwd() + log.PanicIf(err) - currentWd, err := os.Getwd() - log.PanicIf(err) - - assetsPath = path.Join(currentWd, "assets") - } + assetsPath = path.Join(currentWd, "assets") testImageFilepath = path.Join(assetsPath, "NDM_8901.jpg") @@ -181,7 +174,6 @@ func init() { filepath := path.Join(assetsPath, "NDM_8901.jpg.exif") - var err error testExifData, err = ioutil.ReadFile(filepath) log.PanicIf(err) } diff --git a/exif.go b/exif.go index 8d1b848..733728b 100644 --- a/exif.go +++ b/exif.go @@ -5,11 +5,12 @@ import ( "errors" "fmt" "os" + "time" "encoding/binary" "io/ioutil" - "github.com/dsoprea/go-logging" + log "github.com/dsoprea/go-logging" ) const ( @@ -245,3 +246,30 @@ func BuildExifHeader(byteOrder binary.ByteOrder, firstIfdOffset uint32) (headerB return b.Bytes(), nil } + +type ExifInfo struct { + + // The time at which the image was captured. + DateTimeOriginal time.Time + HasDateTimeOriginal bool + + // The offset from UTC + OffsetTimeOriginal time.Duration + HasOffsetTimeOriginal bool +} + +// This function converts the DateTimeOriginal value to UTC if the ExifInfo has an OffsetTimeOriginal value. +// By default we can make no assumptions about the time zone the original date time was captured in. +// We only know the time in UTC if the exif info provides the OffsetTimeOriginal value. +// +// E.g. New York is typically 5h earlier than UTC. During DST New York is only 4h earlier than UTC. +// Given a date time original value of 2022:11:05 17:38:25, and a OffsetTimeOriginal value of -04:00, +// we need to add 4*60*60 seconds to the time in New York to get the time in UTC. +// This finally gives us a time in UTC of 2022:11:05 21:38:25. +func (ei *ExifInfo) DateTimeOriginalInUTC() time.Time { + return ei.DateTimeOriginal.Add(-1 * ei.OffsetTimeOriginal).In(time.UTC) +} + +// func (ei *ExifInfo) String() string { +// return fmt.Sprintf("ExifInfo", gi.Latitude.Decimal(), gi.Longitude.Decimal(), gi.Altitude, gi.Timestamp) +// } diff --git a/go.sum b/go.sum index a36fc59..c989d53 100644 --- a/go.sum +++ b/go.sum @@ -9,6 +9,7 @@ golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553 h1:efeOvDhwQ29Dj3SdAV/MJf8ou golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.7 h1:VUgggvou5XRW9mHwD/yXxIYSMtY0zoKQf/v226p2nyo= gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/ifd_enumerate.go b/ifd_enumerate.go index 317e847..db1e2f9 100644 --- a/ifd_enumerate.go +++ b/ifd_enumerate.go @@ -11,7 +11,7 @@ import ( "encoding/binary" - "github.com/dsoprea/go-logging" + log "github.com/dsoprea/go-logging" ) var ( @@ -997,6 +997,187 @@ func (ifd *Ifd) GpsInfo() (gi *GpsInfo, err error) { return gi, nil } +// ExifInfo parses and consolidates the Exif info. This can only be called on the Exif IFD. +func (ifd *Ifd) ExifInfo() (ei *ExifInfo, err error) { + defer func() { + if state := recover(); state != nil { + err = log.Wrap(state.(error)) + } + }() + + // TODO(dustin): !! Also add functionality to update the GPS info. + + ei = new(ExifInfo) + + if ifd.IfdPath != IfdPathStandardExif { + log.Panicf("Exif can only be read on Exif IFD: [%s] != [%s]", ifd.IfdPath, IfdPathStandardExif) + } + + // Parse Original Time Tags + + dateTimeOriginalTags, foundDateTimeOriginal := ifd.EntriesByTagId[ExifDateTimeOriginal] + if foundDateTimeOriginal { + + // Parse the DateTimeOriginal tag: + dateTimeOriginalValue, err := ifd.TagValue(dateTimeOriginalTags[0]) + log.PanicIf(err) + dateTimeOriginal, err := time.Parse("2006:1:2 15:04:05", dateTimeOriginalValue.(string)) + log.PanicIf(err) + ei.DateTimeOriginal = dateTimeOriginal + ei.HasDateTimeOriginal = true + + // Parse the SubSecTimeOriginal tag if we have one: + subSecTimeOriginalTags, foundSubSecTimeOriginal := ifd.EntriesByTagId[ExifSubSecTimeOriginal] + if foundSubSecTimeOriginal { + subSecTimeOriginalValue, err := ifd.TagValue(subSecTimeOriginalTags[0]) + log.PanicIf(err) + + // The sub-sec time is an integer number of milliseconds formatted as a string. + subSecTimeOriginal, err := strconv.ParseInt(subSecTimeOriginalValue.(string), 10, 64) + log.PanicIf(err) + subSecTimeOriginalDuration := time.Millisecond * time.Duration(subSecTimeOriginal) + dateTimeOriginalWithSubSeconds := ei.DateTimeOriginal.Add(subSecTimeOriginalDuration) + ei.DateTimeOriginal = dateTimeOriginalWithSubSeconds + } + + // Parse the OffsetTimeOriginal tag if we have one: + offsetTimeOriginalTags, foundOffsetTimeOriginal := ifd.EntriesByTagId[ExifOffsetTimeOriginal] + if foundOffsetTimeOriginal { + offsetTimeOriginalValue, err := ifd.TagValue(offsetTimeOriginalTags[0]) + log.PanicIf(err) + + // The offset time is formatted like a time zone but it includes daylight savings time if applicable. + // As such we don't want to treat it as an actual time zone. + // Rather, we want to parse it as a time zone but then convert it to a duration relative to UTC. + offsetTimeOriginal, err := time.Parse("-07:00", offsetTimeOriginalValue.(string)) + log.PanicIf(err) + _, offsetInt := offsetTimeOriginal.Zone() + ei.OffsetTimeOriginal = time.Second * time.Duration(offsetInt) + ei.HasOffsetTimeOriginal = true + } + + // dateParts := strings.Split(datestampValue.(string), ":") + + // year, err1 := strconv.ParseUint(dateParts[0], 10, 16) + // month, err2 := strconv.ParseUint(dateParts[1], 10, 8) + // day, err3 := strconv.ParseUint(dateParts[2], 10, 8) + + // if err1 == nil && err2 == nil && err3 == nil { + // timestampValue, err := ifd.TagValue(timestampTags[0]) + // log.PanicIf(err) + + // timestampRaw := timestampValue.([]Rational) + + // hour := int(timestampRaw[0].Numerator / timestampRaw[0].Denominator) + // minute := int(timestampRaw[1].Numerator / timestampRaw[1].Denominator) + // second := int(timestampRaw[2].Numerator / timestampRaw[2].Denominator) + + // gi.Timestamp = time.Date(int(year), time.Month(month), int(day), hour, minute, second, 0, time.UTC) + // } + } + + // Look for whether North or South. + // tags, found = ifd.EntriesByTagId[TagLatitudeRefId] + // if found == false { + // ifdEnumerateLogger.Warningf(nil, "latitude-ref not found") + // log.Panic(ErrNoGpsTags) + // } + + // latitudeRefValue, err := ifd.TagValue(tags[0]) + // log.PanicIf(err) + + // tags, found = ifd.EntriesByTagId[TagLongitudeId] + // if found == false { + // ifdEnumerateLogger.Warningf(nil, "longitude not found") + // log.Panic(ErrNoGpsTags) + // } + + // longitudeValue, err := ifd.TagValue(tags[0]) + // log.PanicIf(err) + + // Look for whether West or East. + // tags, found = ifd.EntriesByTagId[TagLongitudeRefId] + // if found == false { + // ifdEnumerateLogger.Warningf(nil, "longitude-ref not found") + // log.Panic(ErrNoGpsTags) + // } + + // longitudeRefValue, err := ifd.TagValue(tags[0]) + // log.PanicIf(err) + + // Parse location. + + // latitudeRaw := latitudeValue.([]Rational) + + // ei.Latitude = GpsDegrees{ + // Orientation: latitudeRefValue.(string)[0], + // Degrees: float64(latitudeRaw[0].Numerator) / float64(latitudeRaw[0].Denominator), + // Minutes: float64(latitudeRaw[1].Numerator) / float64(latitudeRaw[1].Denominator), + // Seconds: float64(latitudeRaw[2].Numerator) / float64(latitudeRaw[2].Denominator), + // } + + // longitudeRaw := longitudeValue.([]Rational) + + // ei.Longitude = GpsDegrees{ + // Orientation: longitudeRefValue.(string)[0], + // Degrees: float64(longitudeRaw[0].Numerator) / float64(longitudeRaw[0].Denominator), + // Minutes: float64(longitudeRaw[1].Numerator) / float64(longitudeRaw[1].Denominator), + // Seconds: float64(longitudeRaw[2].Numerator) / float64(longitudeRaw[2].Denominator), + // } + + // Parse altitude. + + // altitudeTags, foundAltitude := ifd.EntriesByTagId[TagAltitudeId] + // altitudeRefTags, foundAltitudeRef := ifd.EntriesByTagId[TagAltitudeRefId] + + // if foundAltitude == true && foundAltitudeRef == true { + // altitudeValue, err := ifd.TagValue(altitudeTags[0]) + // log.PanicIf(err) + + // altitudeRefValue, err := ifd.TagValue(altitudeRefTags[0]) + // log.PanicIf(err) + + // altitudeRaw := altitudeValue.([]Rational) + // altitude := int(altitudeRaw[0].Numerator / altitudeRaw[0].Denominator) + // if altitudeRefValue.([]byte)[0] == 1 { + // altitude *= -1 + // } + + // ei.Altitude = altitude + // } + + // Parse time. + + // timestampTags, foundTimestamp := ifd.EntriesByTagId[TagTimestampId] + // datestampTags, foundDatestamp := ifd.EntriesByTagId[TagDatestampId] + + // if foundTimestamp == true && foundDatestamp == true { + // datestampValue, err := ifd.TagValue(datestampTags[0]) + // log.PanicIf(err) + + // dateParts := strings.Split(datestampValue.(string), ":") + + // year, err1 := strconv.ParseUint(dateParts[0], 10, 16) + // month, err2 := strconv.ParseUint(dateParts[1], 10, 8) + // day, err3 := strconv.ParseUint(dateParts[2], 10, 8) + + // if err1 == nil && err2 == nil && err3 == nil { + // timestampValue, err := ifd.TagValue(timestampTags[0]) + // log.PanicIf(err) + + // timestampRaw := timestampValue.([]Rational) + + // hour := int(timestampRaw[0].Numerator / timestampRaw[0].Denominator) + // minute := int(timestampRaw[1].Numerator / timestampRaw[1].Denominator) + // second := int(timestampRaw[2].Numerator / timestampRaw[2].Denominator) + + // gi.Timestamp = time.Date(int(year), time.Month(month), int(day), hour, minute, second, 0, time.UTC) + // } + // } + + return ei, nil +} + type ParsedTagVisitor func(*Ifd, *IfdTagEntry) error func (ifd *Ifd) EnumerateTagsRecursively(visitor ParsedTagVisitor) (err error) { diff --git a/ifd_enumerate_test.go b/ifd_enumerate_test.go index 3050ac6..b8206aa 100644 --- a/ifd_enumerate_test.go +++ b/ifd_enumerate_test.go @@ -6,11 +6,12 @@ import ( "path" "reflect" "testing" + "time" "encoding/binary" "io/ioutil" - "github.com/dsoprea/go-logging" + log "github.com/dsoprea/go-logging" ) func TestIfdTagEntry_ValueBytes(t *testing.T) { @@ -494,6 +495,36 @@ func ExampleIfd_GpsInfo() { // GpsInfo } +func ExampleIfd_ExifInfo() { + filepath := path.Join(assetsPath, "My_Display_P3.heic") + + rawExif, err := SearchFileAndExtractExif(filepath) + log.PanicIf(err) + + im := NewIfdMapping() + + err = LoadStandardIfds(im) + log.PanicIf(err) + + ti := NewTagIndex() + + _, index, err := Collect(im, ti, rawExif) + log.PanicIf(err) + + ifd, err := index.RootIfd.ChildWithIfdPath(IfdPathStandardExif) + log.PanicIf(err) + + ei, err := ifd.ExifInfo() + log.PanicIf(err) + + loc, _ := time.LoadLocation("America/New_York") + utc := ei.DateTimeOriginalInUTC() + fmt.Printf("%v\n", utc.In(loc).Format(time.Kitchen)) + + // Output: + // GpsInfo +} + func ExampleIfd_FindTagWithName() { rawExif, err := SearchFileAndExtractExif(testImageFilepath) log.PanicIf(err) diff --git a/tags.go b/tags.go index 7f7e515..31d0898 100644 --- a/tags.go +++ b/tags.go @@ -3,17 +3,34 @@ package exif import ( "fmt" - "github.com/dsoprea/go-logging" + log "github.com/dsoprea/go-logging" "gopkg.in/yaml.v2" ) const ( - // IFD1 + + // Exif Tags /////////////////////////////// + // https://exiftool.org/TagNames/EXIF.html // + //////////////////////////////////////////// + + // The date and time when the original image data was generated. + ExifDateTimeOriginal = 0x9003 + // A tag used to record fractions of seconds for the DateTimeOriginal tag. + ExifSubSecTimeOriginal = 0x9291 + // Time difference from Universal Time Coordinated including daylight saving time of DateTimeOriginal tag. + // May be omitted even if DateTimeOriginal is included if the camera doesn't have the capability to determine this. + ExifOffsetTimeOriginal = 0x9011 + + // Backwards-Compatible Exif Tags /////////// + // https://exiftool.org/TagNames/EXIF.html // + ///////////////////////////////////////////// ThumbnailOffsetTagId = 0x0201 ThumbnailSizeTagId = 0x0202 - // Exif + // GPS Tags //////////////////////////////// + // https://exiftool.org/TagNames/GPS.html // + //////////////////////////////////////////// TagVersionId = 0x0000