diff --git a/v2/common/utility.go b/v2/common/utility.go index 8bdb68c..1dc39a7 100644 --- a/v2/common/utility.go +++ b/v2/common/utility.go @@ -3,6 +3,7 @@ package exifcommon import ( "bytes" "fmt" + "time" "github.com/dsoprea/go-logging" ) @@ -68,3 +69,14 @@ func DumpBytesClauseToString(data []byte) string { return b.String() } + +// ExifFullTimestampString produces a string like "2018:11:30 13:01:49" from a +// `time.Time` struct. It will attempt to convert to UTC first. +func ExifFullTimestampString(t time.Time) (fullTimestampPhrase string) { + + // RELEASE(dustin): Dump this for the next release. It duplicates the same function now in exifcommon. + + t = t.UTC() + + return fmt.Sprintf("%04d:%02d:%02d %02d:%02d:%02d", t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second()) +} diff --git a/v2/common/value_encoder.go b/v2/common/value_encoder.go index 58f1fd1..df66486 100644 --- a/v2/common/value_encoder.go +++ b/v2/common/value_encoder.go @@ -3,6 +3,7 @@ package exifcommon import ( "bytes" "reflect" + "time" "encoding/binary" @@ -209,6 +210,15 @@ func (ve *ValueEncoder) Encode(value interface{}) (ed EncodedData, err error) { case []SignedRational: ed, err = ve.encodeSignedRationals(value.([]SignedRational)) log.PanicIf(err) + case time.Time: + // For convenience, if the user doesn't want to deal with translation + // semantics with timestamps. + + t := value.(time.Time) + s := ExifFullTimestampString(t) + + ed, err = ve.encodeAscii(s) + log.PanicIf(err) default: log.Panicf("value not encodable: [%s] [%v]", reflect.TypeOf(value), value) } diff --git a/v2/common/value_encoder_test.go b/v2/common/value_encoder_test.go index e90af57..7fe4cce 100644 --- a/v2/common/value_encoder_test.go +++ b/v2/common/value_encoder_test.go @@ -1,8 +1,10 @@ package exifcommon import ( + "bytes" "reflect" "testing" + "time" "github.com/dsoprea/go-logging" ) @@ -564,3 +566,27 @@ func TestValueEncoder_Encode__SignedRational(t *testing.T) { t.Fatalf("Unit-count not correct.") } } + +func TestValueEncoder_Encode__Timestamp(t *testing.T) { + byteOrder := TestDefaultByteOrder + ve := NewValueEncoder(byteOrder) + + now := time.Now() + + ed, err := ve.Encode(now) + log.PanicIf(err) + + if ed.Type != TypeAscii { + t.Fatalf("Timestamp not encoded as ASCII.") + } + + expectedTimestampBytes := ExifFullTimestampString(now) + + // Leave an extra byte for the NUL. + expected := make([]byte, len(expectedTimestampBytes)+1) + copy(expected, expectedTimestampBytes) + + if bytes.Equal(ed.Encoded, expected) != true { + t.Fatalf("Timestamp not encoded correctly: [%s] != [%s]", string(ed.Encoded), string(expected)) + } +} diff --git a/v2/ifd_builder_test.go b/v2/ifd_builder_test.go index 6d704f3..f092990 100644 --- a/v2/ifd_builder_test.go +++ b/v2/ifd_builder_test.go @@ -7,6 +7,7 @@ import ( "sort" "strings" "testing" + "time" "github.com/dsoprea/go-exif/v2/common" "github.com/dsoprea/go-exif/v2/undefined" @@ -1550,6 +1551,71 @@ func ExampleIfdBuilder_SetStandardWithName_updateGps() { // Degrees } +func ExampleIfdBuilder_SetStandardWithName_timestamp() { + // Check initial value. + + filepath := getTestGpsImageFilepath() + + 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) + + rootIfd := index.RootIfd + + // Update the value. + + rootIb := NewIfdBuilderFromExistingChain(rootIfd) + + exifIb, err := rootIb.ChildWithTagId(exifcommon.IfdExifStandardIfdIdentity.TagId()) + log.PanicIf(err) + + t := time.Date(2020, 06, 7, 1, 30, 0, 0, time.UTC) + + err = exifIb.SetStandardWithName("DateTimeDigitized", t) + log.PanicIf(err) + + // Encode to bytes. + + ibe := NewIfdByteEncoder() + + updatedRawExif, err := ibe.EncodeToExif(rootIb) + log.PanicIf(err) + + // Decode from bytes. + + _, updatedIndex, err := Collect(im, ti, updatedRawExif) + log.PanicIf(err) + + updatedRootIfd := updatedIndex.RootIfd + + // Test. + + updatedExifIfd, err := updatedRootIfd.ChildWithIfdPath(exifcommon.IfdExifStandardIfdIdentity) + log.PanicIf(err) + + results, err := updatedExifIfd.FindTagWithName("DateTimeDigitized") + log.PanicIf(err) + + ite := results[0] + + phrase, err := ite.FormatFirst() + log.PanicIf(err) + + fmt.Printf("%s\n", phrase) + + // Output: + // 2020:06:07 01:30:00 +} + func TestIfdBuilder_NewIfdBuilderFromExistingChain_RealData(t *testing.T) { testImageFilepath := getTestImageFilepath() diff --git a/v2/ifd_tag_entry.go b/v2/ifd_tag_entry.go index 05422aa..658b84f 100644 --- a/v2/ifd_tag_entry.go +++ b/v2/ifd_tag_entry.go @@ -234,6 +234,8 @@ func (ite *IfdTagEntry) FormatFirst() (phrase string, err error) { } }() + // TODO(dustin): We should add a convenience type "timestamp", to simplify translating to and from the physical ASCII and provide validation. + value, err := ite.Value() if err != nil { if err == exifcommon.ErrUnhandledUndefinedTypedTag { diff --git a/v2/tags.go b/v2/tags.go index 422ff97..9c524ee 100644 --- a/v2/tags.go +++ b/v2/tags.go @@ -114,6 +114,12 @@ func (it *IndexedTag) Is(ifdPath string, id uint16) bool { // WidestSupportedType returns the largest type that this tag's value can // occupy func (it *IndexedTag) GetEncodingType(value interface{}) exifcommon.TagTypePrimitive { + // For convenience, we handle encoding a `time.Time` directly. + if IsTime(value) == true { + // Timestamps are encoded as ASCII. + value = "" + } + if len(it.SupportedTypes) == 0 { log.Panicf("IndexedTag [%s] (%d) has no supported types.", it.IfdPath, it.Id) } else if len(it.SupportedTypes) == 1 { diff --git a/v2/tags_test.go b/v2/tags_test.go index 0f85f9c..bab602c 100644 --- a/v2/tags_test.go +++ b/v2/tags_test.go @@ -3,6 +3,7 @@ package exif import ( "reflect" "testing" + "time" "github.com/dsoprea/go-logging" @@ -181,6 +182,20 @@ func TestIndexedTag_GetEncodingType_BothRationalTypes(t *testing.T) { } } +func TestIndexedTag_GetEncodingType_Timestamp(t *testing.T) { + it := &IndexedTag{ + SupportedTypes: []exifcommon.TagTypePrimitive{ + exifcommon.TypeAscii, + }, + } + + zeroTime := time.Time{} + + if it.GetEncodingType(zeroTime) != exifcommon.TypeAscii { + t.Fatalf("Expected the timestamp to to be encoded as ASCII.") + } +} + func TestIndexedTag_DoesSupportType(t *testing.T) { it := &IndexedTag{ Id: 0xb, diff --git a/v2/utility.go b/v2/utility.go index 9b4310d..e860490 100644 --- a/v2/utility.go +++ b/v2/utility.go @@ -3,6 +3,7 @@ package exif import ( "fmt" "math" + "reflect" "strconv" "strings" "time" @@ -17,6 +18,10 @@ var ( utilityLogger = log.NewLogger("exif.utility") ) +var ( + timeType = reflect.TypeOf(time.Time{}) +) + // ParseExifFullTimestamp parses dates like "2018:11:30 13:01:49" into a UTC // `time.Time` struct. func ParseExifFullTimestamp(fullTimestampPhrase string) (timestamp time.Time, err error) { @@ -70,9 +75,10 @@ func ParseExifFullTimestamp(fullTimestampPhrase string) (timestamp time.Time, er // ExifFullTimestampString produces a string like "2018:11:30 13:01:49" from a // `time.Time` struct. It will attempt to convert to UTC first. func ExifFullTimestampString(t time.Time) (fullTimestampPhrase string) { - t = t.UTC() - return fmt.Sprintf("%04d:%02d:%02d %02d:%02d:%02d", t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second()) + // RELEASE(dustin): Dump this for the next release. It duplicates the same function now in exifcommon. + + return exifcommon.ExifFullTimestampString(t) } // ExifTag is one simple representation of a tag in a flat list of all of them. @@ -201,6 +207,7 @@ func GetFlatExifData(exifData []byte) (exifTags []ExifTag, err error) { return exifTags, nil } +// GpsDegreesEquals returns true if the two `GpsDegrees` are identical. func GpsDegreesEquals(gi1, gi2 GpsDegrees) bool { if gi2.Orientation != gi1.Orientation { return false @@ -220,3 +227,8 @@ func GpsDegreesEquals(gi1, gi2 GpsDegrees) bool { return true } + +// IsTime returns true if the value is a `time.Time`. +func IsTime(v interface{}) bool { + return reflect.TypeOf(v) == timeType +}