From c2a4f94de50bb4aae021c5234405d1807a49f57f Mon Sep 17 00:00:00 2001 From: Ralph Slooten Date: Fri, 2 Aug 2019 22:24:42 +1200 Subject: [PATCH 1/9] First commit --- .gitignore | 2 + Makefile | 24 +++++ goptimize.go | 260 +++++++++++++++++++++++++++++++++++++++++++++++++++ main.go | 117 +++++++++++++++++++++++ 4 files changed, 403 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 goptimize.go create mode 100644 main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f85d4b6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/dist/ +goptimize \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e6b514d --- /dev/null +++ b/Makefile @@ -0,0 +1,24 @@ +TAG=`git describe --tags` +VERSION ?= `git describe --tags` +LDFLAGS=-ldflags "-s -extldflags \"--static\" -w -X main.version=${VERSION}" + +build = echo "\n\nBuilding $(1)-$(2)" && GOOS=$(1) GOARCH=$(2) go build ${LDFLAGS} -o dist/goptimize_${VERSION}_$(1)_$(2) \ + && bzip2 dist/goptimize_${VERSION}_$(1)_$(2) + +goptimize: *.go + go get github.com/disintegration/imaging + go build ${LDFLAGS} -o goptimize + rm -rf /tmp/go-* + +clean: + rm -f goptimize + +release: + mkdir -p dist + rm -f dist/goptimize_${VERSION}_* + $(call build,linux,amd64) + $(call build,linux,386) + $(call build,linux,arm) + $(call build,linux,arm64) + $(call build,darwin,amd64) + $(call build,darwin,386) diff --git a/goptimize.go b/goptimize.go new file mode 100644 index 0000000..4a87988 --- /dev/null +++ b/goptimize.go @@ -0,0 +1,260 @@ +package main + +import ( + "fmt" + "image/gif" + "image/jpeg" + "image/png" + "io" + "io/ioutil" + "math" + "os" + "os/exec" + "path/filepath" + + "github.com/disintegration/imaging" + "golang.org/x/image/bmp" + "golang.org/x/image/tiff" +) + +// Goptimize downscales and optimizes an existing image +func Goptimize(file string) { + info, err := os.Stat(file) + if err != nil { + fmt.Printf("%s doesn't exist\n", file) + return + } + if !info.Mode().IsRegular() { + // not a file + fmt.Printf("%s is not a file\n", file) + return + } + // open original, rotate if neccesary + src, err := imaging.Open(file, imaging.AutoOrientation(true)) + if err != nil { + fmt.Printf("%v (%s)\n", err, file) + return + } + + format, err := imaging.FormatFromFilename(file) + if err != nil { + fmt.Printf("Cannot detect format: %v\n", err) + return + } + + outFilename := filepath.Base(file) + outDir := filepath.Dir(file) + dstFile := filepath.Join(outDir, outFilename) + if outputDir != "" { + dstFile = filepath.Join(outputDir, outFilename) + } + + // get original image size + srcBounds := src.Bounds() + srcW := srcBounds.Dx() + srcH := srcBounds.Dy() + + // Ensure scaling does not upscale image + imgMaxW := maxWidth + if imgMaxW == 0 || imgMaxW > srcW { + imgMaxW = srcW + } + imgMaxH := maxHeight + if imgMaxH == 0 || imgMaxH > srcH { + imgMaxH = srcH + } + + resized := imaging.Fit(src, imgMaxW, imgMaxH, imaging.Lanczos) + + dstBounds := resized.Bounds() + resultW := dstBounds.Dx() + resultH := dstBounds.Dy() + + tmpFile, err := ioutil.TempFile(os.TempDir(), "Goptimized-") + if err != nil { + fmt.Printf("Cannot create temporary file: %v\n", err) + return + } + defer os.Remove(tmpFile.Name()) + + if format.String() == "JPEG" { + err = jpeg.Encode(tmpFile, resized, &jpeg.Options{Quality: quality}) + } else if format.String() == "PNG" { + err = png.Encode(tmpFile, resized) + } else if format.String() == "GIF" { + err = gif.Encode(tmpFile, resized, nil) + } else if format.String() == "TIFF" { + err = tiff.Encode(tmpFile, resized, nil) + } else if format.String() == "BMP" { + err = bmp.Encode(tmpFile, resized) + } else { + fmt.Printf("Cannot Goptimize %s files\n", format.String()) + return + } + if err != nil { + fmt.Printf("Error saving output file: %v\n", err) + return + } + + // get the tempoary filename before closing + tmpFilename := tmpFile.Name() + // close the temp file to release pointers so we can + // modify it with system processes + tmpFile.Close() + + // optimize + if format.String() == "JPEG" { + // run one or the other, both don't do anything + if jpegtran != "" { + RunOptimiser(tmpFilename, true, jpegtran, "-optimize", "-outfile") + } else if jpegoptim != "" { + RunOptimiser(tmpFilename, false, jpegoptim, "-f", "-s", "-o") + } + + } else if format.String() == "PNG" { + if pngquant != "" { + RunOptimiser(tmpFilename, true, pngquant, "-f", "--output") + } + if optipng != "" { + RunOptimiser(tmpFilename, true, optipng, "-out") + } + } else if format.String() == "GIF" { + if gifsicle != "" { + RunOptimiser(tmpFilename, true, gifsicle, "-o") + } + } + + // re-open potentiall modified temporary file + tmpFile, err = os.Open(tmpFilename) + if err != nil { + fmt.Printf("Error reopening temporary file: %v\n", err) + return + } + + defer tmpFile.Close() + + // original file stats + srcStat, _ := os.Stat(file) + srcSize := srcStat.Size() + // optimized file stats + dstStat, _ := tmpFile.Stat() + dstSize := dstStat.Size() + + // transfer the original file permissions to the new file + if err = os.Chmod(tmpFile.Name(), srcStat.Mode()); err != nil { + fmt.Printf("Error setting file permissions: %v\n", err) + return + } + + if !skipPreserveModTimes { + // transfer original modification times + mtime := srcStat.ModTime() + atime := mtime // use mtime as we cannot get atime + if err := os.Chtimes(tmpFile.Name(), atime, mtime); err != nil { + fmt.Printf("Error setting file timestamp: %v\n", err) + } + } + + savedPercent := 100 - math.Round(float64(dstSize)/float64(srcSize)*100) + + if dstSize < srcSize { + // output is smaller + if err := os.Rename(tmpFile.Name(), dstFile); err != nil { + fmt.Printf("Error renaming file: %v\n", err) + return + } + fmt.Printf("Goptimized %s (%dx%d %s/%s %v%%)\n", dstFile, resultW, resultH, ByteCountSI(dstSize), ByteCountSI(srcSize), savedPercent) + } else { + if outputDir != "" { + // just copy the original + if err := os.Rename(file, dstFile); err != nil { + fmt.Printf("Error renaming file: %v\n", err) + return + } + } + // we didn't actually any scaling optimizing + fmt.Printf("Goptimized %s (%dx%d %s/%s %v%%)\n", dstFile, srcW, srcH, ByteCountSI(srcSize), ByteCountSI(srcSize), 0) + } + +} + +// RunOptimiser will run the specified command on a copy of the original file +// and overwrite if the output is smaller than the original +func RunOptimiser(src string, outfile bool, args ...string) { + // create a new temp file + tmpFile, err := ioutil.TempFile(os.TempDir(), "Goptimized-") + if err != nil { + fmt.Printf("Cannot create temporary file: %v\n", err) + return + } + defer os.Remove(tmpFile.Name()) + + source, err := os.Open(src) + // s, _ := source.Stat() + // log.Printf("%v\n", s.Size()) + if err != nil { + fmt.Printf("Cannot open temporary file: %v\n", err) + return + } + defer source.Close() + + if _, err := io.Copy(tmpFile, source); err != nil { + fmt.Printf("Cannot copy source file: %v\n", err) + return + } + + // add the filename to the args + args = append(args, tmpFile.Name()) + if outfile { + // most commands require a second filename to overwrite the original + args = append(args, tmpFile.Name()) + } + + // fmt.Println(args) + + // execute the command + cmd := exec.Command(args[0], args[1:]...) + err = cmd.Run() + // out, err := cmd.Output() + if err != nil { + // there was an error + fmt.Printf("%s: %v\n", args[0], err) + return + } + // fmt.Println(string(out)) + + tmpFilename := tmpFile.Name() + + srcStat, _ := source.Stat() + srcSize := srcStat.Size() + dstStat, _ := os.Stat(tmpFilename) + dstSize := dstStat.Size() + + // ensure file pointers are closed before renaming + tmpFile.Close() + source.Close() + + if dstSize < srcSize { + if err := os.Rename(tmpFilename, src); err != nil { + fmt.Printf("Error renaming file: %v\n", err) + return + } + // fmt.Println(args[0], "=", srcSize, "to", dstSize, "(wrote to", source.Name(), ")") + } else { + // fmt.Println(args[0], "!=", srcSize, "to", dstSize) + } +} + +// ByteCountSI returns a human readable size from int64 bytes +func ByteCountSI(b int64) string { + const unit = 1000 + if b < unit { + return fmt.Sprintf("%dB", b) + } + div, exp := int64(unit), 0 + for n := b / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f%cB", float64(b)/float64(div), "kMGTPE"[exp]) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..c7abd67 --- /dev/null +++ b/main.go @@ -0,0 +1,117 @@ +package main + +import ( + "flag" + "fmt" + "os" + "os/exec" + "regexp" + "strconv" +) + +var ( + quality int + maxWidth int + maxHeight int + outputDir string + skipPreserveModTimes bool + jpegoptim string + jpegtran string + optipng string + pngquant string + gifsicle string +) + +func main() { + // modify the default help + flag.Usage = func() { + fmt.Println("Re-save & resample images, with optional optimization.") + fmt.Printf("\nUsage: %s [options] \n", os.Args[0]) + fmt.Println("\nOptions:") + flag.PrintDefaults() + fmt.Println("\nExamples:") + fmt.Printf(" %s -s 800x800 *.jpg\n", os.Args[0]) + fmt.Printf(" %s -o out/ -q 90 -s 1600x1600 *.jpg\n", os.Args[0]) + + fmt.Println("\nOtimizers:") + if err := displayDelectedOptimizer("jpegtran ", jpegtran); err != nil { + displayDelectedOptimizer("jpegoptim", jpegoptim) + } + displayDelectedOptimizer("optipng ", optipng) + displayDelectedOptimizer("pngquant ", pngquant) + displayDelectedOptimizer("gifsicle ", gifsicle) + } + + var maxSizes string + + flag.IntVar(&quality, "q", 75, "Quality - affects jpeg only") + flag.StringVar(&outputDir, "o", "", "Output directory (default overwrites original)") + flag.BoolVar(&skipPreserveModTimes, "n", false, "Do not preserve file modification times") + flag.StringVar(&maxSizes, "m", "", "Scale down to a maximum width & height. Format must be x.") + + // third-party optimizers + flag.StringVar(&gifsicle, "gifsicle", "gifsicle", "Alternative gifsicle name") + flag.StringVar(&jpegoptim, "jpegoptim", "jpegoptim", "Alternative jpegoptim name") + flag.StringVar(&jpegtran, "jpegtran", "jpegtran", "Alternative jpegtran name") + flag.StringVar(&optipng, "optipng", "optipng", "Alternative optipng name") + flag.StringVar(&pngquant, "pngquant", "pngquant", "Alternative pngquant name") + + // parse flags + flag.Parse() + + // detect optimizer paths + gifsicle, _ = exec.LookPath(gifsicle) + jpegoptim, _ = exec.LookPath(jpegoptim) + jpegtran, _ = exec.LookPath(jpegtran) + optipng, _ = exec.LookPath(optipng) + pngquant, _ = exec.LookPath(pngquant) + + if len(flag.Args()) < 1 { + flag.Usage() + return + } + + if maxSizes != "" { + // calculate max sizes from arg[0] + r := regexp.MustCompile(`^(\d+)(x|X|\*|:)(\d+)$`) + matches := r.FindStringSubmatch(maxSizes) + + if len(matches) != 4 { + flag.Usage() + return + } + + maxWidth, _ = strconv.Atoi(matches[1]) + maxHeight, _ = strconv.Atoi(matches[3]) + } + + // parse arguments + args := flag.Args() + + if outputDir != "" { + // ensure the output directory exists + if _, err := os.Stat(outputDir); os.IsNotExist(err) { + err := os.MkdirAll(outputDir, os.ModePerm) + if err != nil { + fmt.Printf("Cannot create output directory: %s\n", outputDir) + return + } + } + } + + for _, img := range args { + Goptimize(img) + } +} + +// displayDelectedOptimizer prints whether the optimizer was found +func displayDelectedOptimizer(name, bin string) error { + exe, err := exec.LookPath(bin) + if err != nil { + // fmt.Printf(" - %s: [undetected]\n", name) + return err + } + + fmt.Printf(" - %s: %s\n", name, exe) + return nil +} From f84a1252dc57f39510110421a4643b5218e56542 Mon Sep 17 00:00:00 2001 From: Ralph Slooten Date: Fri, 2 Aug 2019 23:41:13 +1200 Subject: [PATCH 2/9] Overwrite files instead of moving --- goptimize.go | 147 +++++++++++++++++++++++++++++++-------------------- main.go | 22 ++++---- 2 files changed, 100 insertions(+), 69 deletions(-) diff --git a/goptimize.go b/goptimize.go index 4a87988..9648830 100644 --- a/goptimize.go +++ b/goptimize.go @@ -20,23 +20,28 @@ import ( // Goptimize downscales and optimizes an existing image func Goptimize(file string) { info, err := os.Stat(file) + if err != nil { fmt.Printf("%s doesn't exist\n", file) return } + if !info.Mode().IsRegular() { // not a file fmt.Printf("%s is not a file\n", file) return } + // open original, rotate if neccesary src, err := imaging.Open(file, imaging.AutoOrientation(true)) + if err != nil { fmt.Printf("%v (%s)\n", err, file) return } format, err := imaging.FormatFromFilename(file) + if err != nil { fmt.Printf("Cannot detect format: %v\n", err) return @@ -45,6 +50,7 @@ func Goptimize(file string) { outFilename := filepath.Base(file) outDir := filepath.Dir(file) dstFile := filepath.Join(outDir, outFilename) + if outputDir != "" { dstFile = filepath.Join(outputDir, outFilename) } @@ -59,22 +65,24 @@ func Goptimize(file string) { if imgMaxW == 0 || imgMaxW > srcW { imgMaxW = srcW } + imgMaxH := maxHeight if imgMaxH == 0 || imgMaxH > srcH { imgMaxH = srcH } resized := imaging.Fit(src, imgMaxW, imgMaxH, imaging.Lanczos) - dstBounds := resized.Bounds() resultW := dstBounds.Dx() resultH := dstBounds.Dy() tmpFile, err := ioutil.TempFile(os.TempDir(), "Goptimized-") + if err != nil { fmt.Printf("Cannot create temporary file: %v\n", err) return } + defer os.Remove(tmpFile.Name()) if format.String() == "JPEG" { @@ -88,29 +96,30 @@ func Goptimize(file string) { } else if format.String() == "BMP" { err = bmp.Encode(tmpFile, resized) } else { - fmt.Printf("Cannot Goptimize %s files\n", format.String()) + fmt.Printf("Unsupported file type %s\n", file) return } + if err != nil { fmt.Printf("Error saving output file: %v\n", err) return } - // get the tempoary filename before closing + // get the temporary filename before closing tmpFilename := tmpFile.Name() - // close the temp file to release pointers so we can - // modify it with system processes + + // immediately close the temp file to release pointers + // so we can modify it with system processes tmpFile.Close() - // optimize + // Run through optimizers if format.String() == "JPEG" { - // run one or the other, both don't do anything + // run one or the other, running both has no advantage if jpegtran != "" { RunOptimiser(tmpFilename, true, jpegtran, "-optimize", "-outfile") } else if jpegoptim != "" { RunOptimiser(tmpFilename, false, jpegoptim, "-f", "-s", "-o") } - } else if format.String() == "PNG" { if pngquant != "" { RunOptimiser(tmpFilename, true, pngquant, "-f", "--output") @@ -118,13 +127,11 @@ func Goptimize(file string) { if optipng != "" { RunOptimiser(tmpFilename, true, optipng, "-out") } - } else if format.String() == "GIF" { - if gifsicle != "" { - RunOptimiser(tmpFilename, true, gifsicle, "-o") - } + } else if format.String() == "GIF" && gifsicle != "" { + RunOptimiser(tmpFilename, true, gifsicle, "-o") } - // re-open potentiall modified temporary file + // re-open modified temporary file tmpFile, err = os.Open(tmpFilename) if err != nil { fmt.Printf("Error reopening temporary file: %v\n", err) @@ -133,69 +140,101 @@ func Goptimize(file string) { defer tmpFile.Close() - // original file stats + // get th eoriginal file stats srcStat, _ := os.Stat(file) srcSize := srcStat.Size() - // optimized file stats + // get the optimized file stats dstStat, _ := tmpFile.Stat() dstSize := dstStat.Size() - // transfer the original file permissions to the new file - if err = os.Chmod(tmpFile.Name(), srcStat.Mode()); err != nil { - fmt.Printf("Error setting file permissions: %v\n", err) - return - } - - if !skipPreserveModTimes { - // transfer original modification times - mtime := srcStat.ModTime() - atime := mtime // use mtime as we cannot get atime - if err := os.Chtimes(tmpFile.Name(), atime, mtime); err != nil { - fmt.Printf("Error setting file timestamp: %v\n", err) - } - } + // get the original modification time for later + mtime := srcStat.ModTime() + atime := mtime // use mtime as we cannot get atime + // calculate saved percent savedPercent := 100 - math.Round(float64(dstSize)/float64(srcSize)*100) if dstSize < srcSize { - // output is smaller - if err := os.Rename(tmpFile.Name(), dstFile); err != nil { - fmt.Printf("Error renaming file: %v\n", err) + // (over)write the file - not all filesystems support + // cross-filesystem moving so we overwrite the original + out, err := os.Create(dstFile) + if err != nil { + fmt.Printf("Error opening original file: %v\n", err) return } - fmt.Printf("Goptimized %s (%dx%d %s/%s %v%%)\n", dstFile, resultW, resultH, ByteCountSI(dstSize), ByteCountSI(srcSize), savedPercent) - } else { - if outputDir != "" { - // just copy the original - if err := os.Rename(file, dstFile); err != nil { - fmt.Printf("Error renaming file: %v\n", err) - return + + defer out.Close() + + if _, err := io.Copy(out, tmpFile); err != nil { + fmt.Printf("Error ovewriting original file: %v\n", err) + return + } + + if !skipPreserveModTimes { + // transfer original modification times + if err := os.Chtimes(dstFile, atime, mtime); err != nil { + fmt.Printf("Error setting file timestamp: %v\n", err) } } - // we didn't actually any scaling optimizing - fmt.Printf("Goptimized %s (%dx%d %s/%s %v%%)\n", dstFile, srcW, srcH, ByteCountSI(srcSize), ByteCountSI(srcSize), 0) + + fmt.Printf("Goptimized %s (%dx%d %s->%s %v%%)\n", dstFile, resultW, resultH, ByteCountSI(srcSize), ByteCountSI(dstSize), savedPercent) + } else { + // If the output directory is not the same, + // then write a copy of the original file + if outputDir != "" { + out, err := os.Create(dstFile) + if err != nil { + fmt.Printf("Error opening original file: %v\n", err) + return + } + + defer out.Close() + + orig, _ := os.Open(file) + + defer orig.Close() + + if _, err := io.Copy(out, orig); err != nil { + fmt.Printf("Error ovewriting original file: %v\n", err) + return + } + + if !skipPreserveModTimes { + // transfer original modification times + if err := os.Chtimes(dstFile, atime, mtime); err != nil { + fmt.Printf("Error setting file timestamp: %v\n", err) + } + } + + fmt.Printf("Copied %s (%dx%d %s->%s %v%%)\n", dstFile, srcW, srcH, ByteCountSI(srcSize), ByteCountSI(srcSize), 0) + } else { + // we didn't actually change anything + fmt.Printf("Skipped %s (%dx%d %s->%s %v%%)\n", dstFile, srcW, srcH, ByteCountSI(srcSize), ByteCountSI(srcSize), 0) + } } } -// RunOptimiser will run the specified command on a copy of the original file -// and overwrite if the output is smaller than the original -func RunOptimiser(src string, outfile bool, args ...string) { +// RunOptimiser will run the specified command on a copy of the temporary file, +// and overwrite it if the output is smaller than the original +func RunOptimiser(src string, outFileArg bool, args ...string) { // create a new temp file tmpFile, err := ioutil.TempFile(os.TempDir(), "Goptimized-") + if err != nil { fmt.Printf("Cannot create temporary file: %v\n", err) return } + defer os.Remove(tmpFile.Name()) source, err := os.Open(src) - // s, _ := source.Stat() - // log.Printf("%v\n", s.Size()) + if err != nil { fmt.Printf("Cannot open temporary file: %v\n", err) return } + defer source.Close() if _, err := io.Copy(tmpFile, source); err != nil { @@ -205,23 +244,18 @@ func RunOptimiser(src string, outfile bool, args ...string) { // add the filename to the args args = append(args, tmpFile.Name()) - if outfile { - // most commands require a second filename to overwrite the original + if outFileArg { + // most commands require a second filename arg to overwrite the original args = append(args, tmpFile.Name()) } - // fmt.Println(args) - // execute the command cmd := exec.Command(args[0], args[1:]...) - err = cmd.Run() - // out, err := cmd.Output() - if err != nil { - // there was an error + + if err := cmd.Run(); err != nil { fmt.Printf("%s: %v\n", args[0], err) return } - // fmt.Println(string(out)) tmpFilename := tmpFile.Name() @@ -239,9 +273,6 @@ func RunOptimiser(src string, outfile bool, args ...string) { fmt.Printf("Error renaming file: %v\n", err) return } - // fmt.Println(args[0], "=", srcSize, "to", dstSize, "(wrote to", source.Name(), ")") - } else { - // fmt.Println(args[0], "!=", srcSize, "to", dstSize) } } diff --git a/main.go b/main.go index c7abd67..d45c565 100644 --- a/main.go +++ b/main.go @@ -23,17 +23,17 @@ var ( ) func main() { - // modify the default help + // set the default help flag.Usage = func() { - fmt.Println("Re-save & resample images, with optional optimization.") + fmt.Println("Goptimize - Resample optimized images") fmt.Printf("\nUsage: %s [options] \n", os.Args[0]) fmt.Println("\nOptions:") flag.PrintDefaults() fmt.Println("\nExamples:") - fmt.Printf(" %s -s 800x800 *.jpg\n", os.Args[0]) - fmt.Printf(" %s -o out/ -q 90 -s 1600x1600 *.jpg\n", os.Args[0]) + fmt.Printf(" %s -m 800x800 *.jpg\n", os.Args[0]) + fmt.Printf(" %s -o out/ -q 90 -m 1600x1600 *.jpg\n", os.Args[0]) - fmt.Println("\nOtimizers:") + fmt.Println("\nDetected optimizers:") if err := displayDelectedOptimizer("jpegtran ", jpegtran); err != nil { displayDelectedOptimizer("jpegoptim", jpegoptim) } @@ -44,17 +44,17 @@ func main() { var maxSizes string - flag.IntVar(&quality, "q", 75, "Quality - affects jpeg only") + flag.IntVar(&quality, "q", 75, "Quality - JPEG only") flag.StringVar(&outputDir, "o", "", "Output directory (default overwrites original)") flag.BoolVar(&skipPreserveModTimes, "n", false, "Do not preserve file modification times") flag.StringVar(&maxSizes, "m", "", "Scale down to a maximum width & height. Format must be x.") // third-party optimizers - flag.StringVar(&gifsicle, "gifsicle", "gifsicle", "Alternative gifsicle name") - flag.StringVar(&jpegoptim, "jpegoptim", "jpegoptim", "Alternative jpegoptim name") - flag.StringVar(&jpegtran, "jpegtran", "jpegtran", "Alternative jpegtran name") - flag.StringVar(&optipng, "optipng", "optipng", "Alternative optipng name") - flag.StringVar(&pngquant, "pngquant", "pngquant", "Alternative pngquant name") + flag.StringVar(&gifsicle, "gifsicle", "gifsicle", "gifsicle binary") + flag.StringVar(&jpegoptim, "jpegoptim", "jpegoptim", "jpegoptim binary") + flag.StringVar(&jpegtran, "jpegtran", "jpegtran", "jpegtran binary") + flag.StringVar(&optipng, "optipng", "optipng", "optipng binary") + flag.StringVar(&pngquant, "pngquant", "pngquant", "pngquant binary") // parse flags flag.Parse() From 3a09a2e666a506b137903614eff8a9aca34d0d4d Mon Sep 17 00:00:00 2001 From: Ralph Slooten Date: Fri, 2 Aug 2019 23:44:11 +1200 Subject: [PATCH 3/9] Add license --- LICENSE | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2b9c19d --- /dev/null +++ b/LICENSE @@ -0,0 +1,16 @@ +Copyright 2019 Ralph Slooten + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING +BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. From 2af6ad556631573b52286f8d6779bd282ed7601c Mon Sep 17 00:00:00 2001 From: Ralph Slooten Date: Sat, 3 Aug 2019 00:37:51 +1200 Subject: [PATCH 4/9] Update flags --- goptimize.go | 12 ++++++------ main.go | 27 ++++++++++++++------------- 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/goptimize.go b/goptimize.go index 9648830..17ee5bf 100644 --- a/goptimize.go +++ b/goptimize.go @@ -154,7 +154,7 @@ func Goptimize(file string) { // calculate saved percent savedPercent := 100 - math.Round(float64(dstSize)/float64(srcSize)*100) - if dstSize < srcSize { + if savedPercent > 0 { // (over)write the file - not all filesystems support // cross-filesystem moving so we overwrite the original out, err := os.Create(dstFile) @@ -170,14 +170,14 @@ func Goptimize(file string) { return } - if !skipPreserveModTimes { + if preserveModificationTimes { // transfer original modification times if err := os.Chtimes(dstFile, atime, mtime); err != nil { fmt.Printf("Error setting file timestamp: %v\n", err) } } - fmt.Printf("Goptimized %s (%dx%d %s->%s %v%%)\n", dstFile, resultW, resultH, ByteCountSI(srcSize), ByteCountSI(dstSize), savedPercent) + fmt.Printf("Goptimized %s (%dx%d %s > %s %v%%)\n", dstFile, resultW, resultH, ByteCountSI(srcSize), ByteCountSI(dstSize), savedPercent) } else { // If the output directory is not the same, // then write a copy of the original file @@ -199,17 +199,17 @@ func Goptimize(file string) { return } - if !skipPreserveModTimes { + if preserveModificationTimes { // transfer original modification times if err := os.Chtimes(dstFile, atime, mtime); err != nil { fmt.Printf("Error setting file timestamp: %v\n", err) } } - fmt.Printf("Copied %s (%dx%d %s->%s %v%%)\n", dstFile, srcW, srcH, ByteCountSI(srcSize), ByteCountSI(srcSize), 0) + fmt.Printf("Copied %s (%dx%d %s %v%%)\n", dstFile, srcW, srcH, ByteCountSI(srcSize), 0) } else { // we didn't actually change anything - fmt.Printf("Skipped %s (%dx%d %s->%s %v%%)\n", dstFile, srcW, srcH, ByteCountSI(srcSize), ByteCountSI(srcSize), 0) + fmt.Printf("Skipped %s (%dx%d %s %v%%)\n", dstFile, srcW, srcH, ByteCountSI(srcSize), 0) } } diff --git a/main.go b/main.go index d45c565..0a202f7 100644 --- a/main.go +++ b/main.go @@ -10,26 +10,27 @@ import ( ) var ( - quality int - maxWidth int - maxHeight int - outputDir string - skipPreserveModTimes bool - jpegoptim string - jpegtran string - optipng string - pngquant string - gifsicle string + quality int + maxWidth int + maxHeight int + outputDir string + preserveModificationTimes bool + jpegoptim string + jpegtran string + optipng string + pngquant string + gifsicle string ) func main() { // set the default help flag.Usage = func() { - fmt.Println("Goptimize - Resample optimized images") + fmt.Println("Goptimize - downscales and optimizes existing images") fmt.Printf("\nUsage: %s [options] \n", os.Args[0]) fmt.Println("\nOptions:") flag.PrintDefaults() fmt.Println("\nExamples:") + fmt.Printf(" %s image.png\n", os.Args[0]) fmt.Printf(" %s -m 800x800 *.jpg\n", os.Args[0]) fmt.Printf(" %s -o out/ -q 90 -m 1600x1600 *.jpg\n", os.Args[0]) @@ -46,8 +47,8 @@ func main() { flag.IntVar(&quality, "q", 75, "Quality - JPEG only") flag.StringVar(&outputDir, "o", "", "Output directory (default overwrites original)") - flag.BoolVar(&skipPreserveModTimes, "n", false, "Do not preserve file modification times") - flag.StringVar(&maxSizes, "m", "", "Scale down to a maximum width & height. Format must be x.") + flag.BoolVar(&preserveModificationTimes, "p", true, "Preserve file modification times") + flag.StringVar(&maxSizes, "m", "", "Downscale to a maximum width & height in pixels (x)") // third-party optimizers flag.StringVar(&gifsicle, "gifsicle", "gifsicle", "gifsicle binary") From d2857f94b72e830388eb8e8cb51206fad04bff56 Mon Sep 17 00:00:00 2001 From: Ralph Slooten Date: Sat, 3 Aug 2019 00:46:32 +1200 Subject: [PATCH 5/9] Update flag info --- main.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/main.go b/main.go index 0a202f7..412fb7b 100644 --- a/main.go +++ b/main.go @@ -25,7 +25,7 @@ var ( func main() { // set the default help flag.Usage = func() { - fmt.Println("Goptimize - downscales and optimizes existing images") + fmt.Println("Goptimize - downscales and optimizes images") fmt.Printf("\nUsage: %s [options] \n", os.Args[0]) fmt.Println("\nOptions:") flag.PrintDefaults() @@ -45,10 +45,10 @@ func main() { var maxSizes string - flag.IntVar(&quality, "q", 75, "Quality - JPEG only") - flag.StringVar(&outputDir, "o", "", "Output directory (default overwrites original)") - flag.BoolVar(&preserveModificationTimes, "p", true, "Preserve file modification times") - flag.StringVar(&maxSizes, "m", "", "Downscale to a maximum width & height in pixels (x)") + flag.IntVar(&quality, "q", 75, "quality - JPEG only") + flag.StringVar(&outputDir, "o", "", "output directory (default overwrites original)") + flag.BoolVar(&preserveModificationTimes, "p", true, "preserve file modification times") + flag.StringVar(&maxSizes, "m", "", "downscale to a maximum width & height in pixels (x)") // third-party optimizers flag.StringVar(&gifsicle, "gifsicle", "gifsicle", "gifsicle binary") From bce966fb7bfa675fdc8bb60f03c51501cad2b2fb Mon Sep 17 00:00:00 2001 From: Ralph Slooten Date: Sat, 3 Aug 2019 00:46:41 +1200 Subject: [PATCH 6/9] Add README --- README.md | 56 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..5d66c0d --- /dev/null +++ b/README.md @@ -0,0 +1,56 @@ +# Goptimizer - downscales and optimizes images + +Goptimizer is a commandline utility written in Golang. It downscales and optimize existing images JPEG, PNG and Gif files. + +Image downscaling is done within Goptimize (`-m x`, see [Usage](#usage-options)), however optimization is done using the following additional tools (if they are installed): + +- jpegoptim +- jpegtran (`libjpeg-turbo-progs`) +- optipng +- pngquant +- gifsicle + + +## Notes + +Both `jpegoptim` & `jpegtran` have almost identical optimization, so if both are installed then just `jpegtran` is used for JPG optimization. PNG optimization however will run through both `optipng` & `pngquant` (if installed) as this has definite advantages. + +It is highly recommended to install the necessary optimization tools, however they are not required to run goptimize. + +Goptimize will remove all exif data from JPEG files, auto-rotating those that relied on it. + +It will also preserve (by default) the file's original modification times (`-p=false` to disable). + + +## Usage options + +``` +Usage: ./goptimize [options] + +Options: + -gifsicle string + gifsicle binary (default "gifsicle") + -jpegoptim string + jpegoptim binary (default "jpegoptim") + -jpegtran string + jpegtran binary (default "jpegtran") + -m string + downscale to a maximum width & height in pixels (x) + -o string + output directory (default overwrites original) + -optipng string + optipng binary (default "optipng") + -p preserve file modification times (default true) + -pngquant string + pngquant binary (default "pngquant") + -q int + quality - JPEG only (default 75) +``` + + +## Examples + +- `./goptimize image.png` - optimize a PNG file +- `./goptimize -m 800x800 *` - optimize and downscale all image files to a maximum size of 800x800px +- `./goptimize -m 1200x0 image.jpg` - optimize and downscale a JPG file to a maximum size of width of 1200px +- `./goptimize -o out/ image.jpg` - optimize a JPG file and save it to `out/` From ed1a7b3b39c281bff752c7c0e02f2cb091336589 Mon Sep 17 00:00:00 2001 From: Ralph Slooten Date: Sat, 3 Aug 2019 10:23:31 +1200 Subject: [PATCH 7/9] Add updater --- goptimize.go | 4 ++-- main.go | 54 +++++++++++++++++++++++++++++++++++++--------------- 2 files changed, 41 insertions(+), 17 deletions(-) diff --git a/goptimize.go b/goptimize.go index 17ee5bf..999c0ec 100644 --- a/goptimize.go +++ b/goptimize.go @@ -170,7 +170,7 @@ func Goptimize(file string) { return } - if preserveModificationTimes { + if preserveModTimes { // transfer original modification times if err := os.Chtimes(dstFile, atime, mtime); err != nil { fmt.Printf("Error setting file timestamp: %v\n", err) @@ -199,7 +199,7 @@ func Goptimize(file string) { return } - if preserveModificationTimes { + if preserveModTimes { // transfer original modification times if err := os.Chtimes(dstFile, atime, mtime); err != nil { fmt.Printf("Error setting file timestamp: %v\n", err) diff --git a/main.go b/main.go index 412fb7b..36dd102 100644 --- a/main.go +++ b/main.go @@ -7,19 +7,22 @@ import ( "os/exec" "regexp" "strconv" + + "github.com/axllent/gitrel" ) var ( - quality int - maxWidth int - maxHeight int - outputDir string - preserveModificationTimes bool - jpegoptim string - jpegtran string - optipng string - pngquant string - gifsicle string + quality int + maxWidth int + maxHeight int + outputDir string + preserveModTimes bool + jpegoptim string + jpegtran string + optipng string + pngquant string + gifsicle string + version = "dev" ) func main() { @@ -44,11 +47,14 @@ func main() { } var maxSizes string + var update, showversion bool flag.IntVar(&quality, "q", 75, "quality - JPEG only") flag.StringVar(&outputDir, "o", "", "output directory (default overwrites original)") - flag.BoolVar(&preserveModificationTimes, "p", true, "preserve file modification times") + flag.BoolVar(&preserveModTimes, "p", true, "preserve file modification times") flag.StringVar(&maxSizes, "m", "", "downscale to a maximum width & height in pixels (x)") + flag.BoolVar(&update, "u", false, "update to latest release") + flag.BoolVar(&showversion, "v", false, "show version number") // third-party optimizers flag.StringVar(&gifsicle, "gifsicle", "gifsicle", "gifsicle binary") @@ -67,9 +73,28 @@ func main() { optipng, _ = exec.LookPath(optipng) pngquant, _ = exec.LookPath(pngquant) + if showversion { + fmt.Println(fmt.Sprintf("Version: %s", version)) + latest, _, _, err := gitrel.Latest("axllent/goptimize", "goptimize") + if err == nil && latest != version { + fmt.Printf("Update available: %s\nRun `%s -u` to update.\n", latest, os.Args[0]) + } + return + } + + if update { + rel, err := gitrel.Update("axllent/goptimize", "goptimize", version) + if err != nil { + fmt.Println(err) + os.Exit(1) + } + fmt.Printf("Updated %s to version %s", os.Args[0], rel) + return + } + if len(flag.Args()) < 1 { flag.Usage() - return + os.Exit(1) } if maxSizes != "" { @@ -79,7 +104,7 @@ func main() { if len(matches) != 4 { flag.Usage() - return + os.Exit(1) } maxWidth, _ = strconv.Atoi(matches[1]) @@ -95,7 +120,7 @@ func main() { err := os.MkdirAll(outputDir, os.ModePerm) if err != nil { fmt.Printf("Cannot create output directory: %s\n", outputDir) - return + os.Exit(1) } } } @@ -109,7 +134,6 @@ func main() { func displayDelectedOptimizer(name, bin string) error { exe, err := exec.LookPath(bin) if err != nil { - // fmt.Printf(" - %s: [undetected]\n", name) return err } From a1d8d70461821e580e932e3c1a44d0999d1b3a06 Mon Sep 17 00:00:00 2001 From: Ralph Slooten Date: Sat, 3 Aug 2019 10:23:58 +1200 Subject: [PATCH 8/9] LDFLAGS --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index e6b514d..b536deb 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ TAG=`git describe --tags` VERSION ?= `git describe --tags` -LDFLAGS=-ldflags "-s -extldflags \"--static\" -w -X main.version=${VERSION}" +LDFLAGS=-ldflags "-s -w -X main.version=${VERSION}" build = echo "\n\nBuilding $(1)-$(2)" && GOOS=$(1) GOARCH=$(2) go build ${LDFLAGS} -o dist/goptimize_${VERSION}_$(1)_$(2) \ && bzip2 dist/goptimize_${VERSION}_$(1)_$(2) From a1dcdb5bc7c7e22a0af93506e7f7114330a3687b Mon Sep 17 00:00:00 2001 From: Ralph Slooten Date: Sat, 3 Aug 2019 10:39:44 +1200 Subject: [PATCH 9/9] Add changelog --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b318eb6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## [0.0.1] + +- Initial release