diff --git a/CHANGELOG.md b/CHANGELOG.md index e33ee6a..cb57f81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [0.2.2] + +- Optionally preserve exif data for supported formats +- Update Go modules + + ## [0.2.1] - Update core modules diff --git a/exifcopy.go b/exifcopy.go new file mode 100644 index 0000000..8285782 --- /dev/null +++ b/exifcopy.go @@ -0,0 +1,171 @@ +// https://stackoverflow.com/a/76779756 +package main + +import ( + "bufio" + "errors" + "io" + "os" +) + +const ( + soi = 0xD8 + eoi = 0xD9 + sos = 0xDA + exif = 0xE1 + copyright = 0xEE + comment = 0xFE +) + +func isMetaTagType(tagType byte) bool { + // Adapt as needed + return tagType == exif || tagType == copyright || tagType == comment +} + +func copySegments(dst *bufio.Writer, src *bufio.Reader, filterSegment func(tagType byte) bool) error { + var buf [2]byte + _, err := io.ReadFull(src, buf[:]) + if err != nil { + return err + } + if buf != [2]byte{0xFF, soi} { + return errors.New("expected SOI") + } + for { + _, err := io.ReadFull(src, buf[:]) + if err != nil { + return err + } + if buf[0] != 0xFF { + return errors.New("invalid tag type") + } + if buf[1] == eoi { + // Hacky way to check for EOF + _, err := src.Read(buf[:1]) + if err != nil && err != io.EOF { + return err + } + // don't return an error as some cameras add the exif data at the end. + // if n > 0 { + // return errors.New("EOF expected after EOI") + // } + return nil + } + sos := buf[1] == 0xDA + filter := filterSegment(buf[1]) + if filter { + _, err = dst.Write(buf[:]) + if err != nil { + return err + } + } + + _, err = io.ReadFull(src, buf[:]) + if err != nil { + return err + } + if filter { + _, err = dst.Write(buf[:]) + if err != nil { + return err + } + } + + // Note: Includes the length, but not the tag, so subtract 2 + tagLength := ((uint16(buf[0]) << 8) | uint16(buf[1])) - 2 + if filter { + _, err = io.CopyN(dst, src, int64(tagLength)) + } else { + _, err = src.Discard(int(tagLength)) + } + if err != nil { + return err + } + if sos { + // Find next tag `FF xx` in the stream where `xx != 0` to skip ECS + // See https://stackoverflow.com/questions/2467137/parsing-jpeg-file-format-format-of-entropy-coded-segments-ecs + for { + bytes, err := src.Peek(2) + if err != nil { + return err + } + if bytes[0] == 0xFF { + data, rstMrk := bytes[1] == 0, bytes[1] >= 0xD0 && bytes[1] <= 0xD7 + if !data && !rstMrk { + break + } + } + if filter { + err = dst.WriteByte(bytes[0]) + if err != nil { + return err + } + } + _, err = src.Discard(1) + if err != nil { + return err + } + } + } + } +} + +func copyMetadata(outImagePath, imagePath, metadataImagePath string) error { + outFile, err := os.Create(outImagePath) + if err != nil { + return err + } + defer outFile.Close() + writer := bufio.NewWriter(outFile) + + imageFile, err := os.Open(imagePath) + if err != nil { + return err + } + defer imageFile.Close() + imageReader := bufio.NewReader(imageFile) + + metaFile, err := os.Open(metadataImagePath) + if err != nil { + return err + } + defer metaFile.Close() + metaReader := bufio.NewReader(metaFile) + + _, err = writer.Write([]byte{0xFF, soi}) + if err != nil { + return err + } + + // Copy metadata segments + // It seems that they need to come first! + err = copySegments(writer, metaReader, isMetaTagType) + if err != nil { + return err + } + // Copy all non-metadata segments + err = copySegments(writer, imageReader, func(tagType byte) bool { + return !isMetaTagType(tagType) + }) + if err != nil { + return err + } + + _, err = writer.Write([]byte{0xFF, eoi}) + if err != nil { + return err + } + + // Flush the writer, otherwise the last couple buffered writes (including the EOI) won't get written! + return writer.Flush() +} + +func exifCopy(fromPath, toPath string) error { + copyPath := toPath + "~" + err := os.Rename(toPath, copyPath) + if err != nil { + return err + } + defer os.Remove(copyPath) + return copyMetadata(toPath, copyPath, fromPath) +} diff --git a/go.mod b/go.mod index a8230d1..9198cd2 100644 --- a/go.mod +++ b/go.mod @@ -6,5 +6,5 @@ require ( github.com/axllent/ghru v1.2.1 github.com/disintegration/imaging v1.6.2 github.com/spf13/pflag v1.0.5 - golang.org/x/image v0.5.0 + golang.org/x/image v0.14.0 ) diff --git a/go.sum b/go.sum index 9e5817b..4635347 100644 --- a/go.sum +++ b/go.sum @@ -10,26 +10,33 @@ github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5t golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/image v0.5.0 h1:5JMiNunQeQw++mMOz48/ISeNu3Iweh/JaZU8ZLqHRrI= -golang.org/x/image v0.5.0/go.mod h1:FVC7BI/5Ym8R25iw5OLsgshdUBbT1h5jZTpA+mvAdZ4= +golang.org/x/image v0.14.0 h1:tNgSxAFe3jC4uYqvZdTr84SZoM1KfwdC9SKIFrLjFn4= +golang.org/x/image v0.14.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/goptimize.go b/goptimize.go index 2bb3783..680549c 100644 --- a/goptimize.go +++ b/goptimize.go @@ -2,11 +2,11 @@ package main import ( "fmt" + "image" "image/gif" "image/jpeg" "image/png" "io" - "io/ioutil" "math" "os" "os/exec" @@ -31,12 +31,21 @@ func Goptimize(file string) { return } - // open original, rotate if necessary - src, err := imaging.Open(file, imaging.AutoOrientation(true)) + var src image.Image - if err != nil { - fmt.Printf("Error: %v (%s)\n", err, file) - return + if !copyExif { + // rotate if necessary + src, err = imaging.Open(file, imaging.AutoOrientation(true)) + if err != nil { + fmt.Printf("Error: %v (%s)\n", err, file) + return + } + } else { + src, err = imaging.Open(file) + if err != nil { + fmt.Printf("Error: %v (%s)\n", err, file) + return + } } format, err := imaging.FormatFromFilename(file) @@ -83,7 +92,7 @@ func Goptimize(file string) { resultW := dstBounds.Dx() resultH := dstBounds.Dy() - tmpFile, err := ioutil.TempFile(os.TempDir(), "Goptimized-") + tmpFile, err := os.CreateTemp(os.TempDir(), "Goptimized-") if err != nil { fmt.Printf("Error: cannot create temporary file: %v\n", err) @@ -128,6 +137,13 @@ func Goptimize(file string) { } else if jpegoptim != "" { RunOptimizer(tmpFilename, false, jpegoptim, "-f", "-s", "-o") } + + if copyExif { + if err := exifCopy(file, tmpFilename); err != nil { + fmt.Printf("Error copying exif data: %v (%s)\n", err, file) + return + } + } } else if format.String() == "PNG" { if pngquant != "" { RunOptimizer(tmpFilename, true, pngquant, "-f", "--output") @@ -227,7 +243,7 @@ func Goptimize(file string) { // and overwrite it if the output is smaller than the original func RunOptimizer(src string, outFileArg bool, args ...string) { // create a new temp file - tmpFile, err := ioutil.TempFile(os.TempDir(), "Goptimized-") + tmpFile, err := os.CreateTemp(os.TempDir(), "Goptimized-") if err != nil { fmt.Printf("Cannot create temporary file: %v\n", err) diff --git a/main.go b/main.go index f2dca87..23eb3e6 100644 --- a/main.go +++ b/main.go @@ -24,6 +24,7 @@ var ( optipng string pngquant string gifsicle string + copyExif bool threads = 1 version = "dev" ) @@ -60,6 +61,7 @@ func main() { flag.StringVarP(&maxSizes, "max", "m", "", "downscale to a maximum width & height in pixels (x)") flag.StringVarP(&outputDir, "out", "o", "", "output directory (default overwrites original)") flag.BoolVarP(&preserveModTimes, "preserve", "p", true, "preserve file modification times") + flag.BoolVarP(©Exif, "exif", "e", false, "copy exif data") flag.BoolVarP(&update, "update", "u", false, "update to latest release") flag.BoolVarP(&multiThreaded, "threaded", "t", false, "run multi-threaded (use all CPU cores)") flag.BoolVarP(&showversion, "version", "v", false, "show version number")