Merge branch 'release/0.0.1'

This commit is contained in:
Ralph Slooten 2019-08-03 10:40:00 +12:00
commit 5e04891863
7 changed files with 536 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/dist/
goptimize

5
CHANGELOG.md Normal file
View File

@ -0,0 +1,5 @@
# Changelog
## [0.0.1]
- Initial release

16
LICENSE Normal file
View File

@ -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.

24
Makefile Normal file
View File

@ -0,0 +1,24 @@
TAG=`git describe --tags`
VERSION ?= `git describe --tags`
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)
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)

56
README.md Normal file
View File

@ -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 <width>x<height>`, 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] <images>
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 (<width>x<height>)
-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/`

291
goptimize.go Normal file
View File

@ -0,0 +1,291 @@
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("Unsupported file type %s\n", file)
return
}
if err != nil {
fmt.Printf("Error saving output file: %v\n", err)
return
}
// get the temporary filename before closing
tmpFilename := tmpFile.Name()
// immediately close the temp file to release pointers
// so we can modify it with system processes
tmpFile.Close()
// Run through optimizers
if format.String() == "JPEG" {
// 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")
}
if optipng != "" {
RunOptimiser(tmpFilename, true, optipng, "-out")
}
} else if format.String() == "GIF" && gifsicle != "" {
RunOptimiser(tmpFilename, true, gifsicle, "-o")
}
// re-open modified temporary file
tmpFile, err = os.Open(tmpFilename)
if err != nil {
fmt.Printf("Error reopening temporary file: %v\n", err)
return
}
defer tmpFile.Close()
// get th eoriginal file stats
srcStat, _ := os.Stat(file)
srcSize := srcStat.Size()
// get the optimized file stats
dstStat, _ := tmpFile.Stat()
dstSize := dstStat.Size()
// 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 savedPercent > 0 {
// (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
}
defer out.Close()
if _, err := io.Copy(out, tmpFile); err != nil {
fmt.Printf("Error ovewriting original file: %v\n", err)
return
}
if preserveModTimes {
// 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)
} 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 preserveModTimes {
// 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 %v%%)\n", dstFile, srcW, srcH, ByteCountSI(srcSize), 0)
} else {
// we didn't actually change anything
fmt.Printf("Skipped %s (%dx%d %s %v%%)\n", dstFile, srcW, srcH, ByteCountSI(srcSize), 0)
}
}
}
// 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)
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 outFileArg {
// most commands require a second filename arg to overwrite the original
args = append(args, tmpFile.Name())
}
// execute the command
cmd := exec.Command(args[0], args[1:]...)
if err := cmd.Run(); err != nil {
fmt.Printf("%s: %v\n", args[0], err)
return
}
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
}
}
}
// 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])
}

142
main.go Normal file
View File

@ -0,0 +1,142 @@
package main
import (
"flag"
"fmt"
"os"
"os/exec"
"regexp"
"strconv"
"github.com/axllent/gitrel"
)
var (
quality int
maxWidth int
maxHeight int
outputDir string
preserveModTimes bool
jpegoptim string
jpegtran string
optipng string
pngquant string
gifsicle string
version = "dev"
)
func main() {
// set the default help
flag.Usage = func() {
fmt.Println("Goptimize - downscales and optimizes images")
fmt.Printf("\nUsage: %s [options] <images>\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])
fmt.Println("\nDetected optimizers:")
if err := displayDelectedOptimizer("jpegtran ", jpegtran); err != nil {
displayDelectedOptimizer("jpegoptim", jpegoptim)
}
displayDelectedOptimizer("optipng ", optipng)
displayDelectedOptimizer("pngquant ", pngquant)
displayDelectedOptimizer("gifsicle ", gifsicle)
}
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(&preserveModTimes, "p", true, "preserve file modification times")
flag.StringVar(&maxSizes, "m", "", "downscale to a maximum width & height in pixels (<width>x<height>)")
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")
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()
// detect optimizer paths
gifsicle, _ = exec.LookPath(gifsicle)
jpegoptim, _ = exec.LookPath(jpegoptim)
jpegtran, _ = exec.LookPath(jpegtran)
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()
os.Exit(1)
}
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()
os.Exit(1)
}
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)
os.Exit(1)
}
}
}
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 {
return err
}
fmt.Printf(" - %s: %s\n", name, exe)
return nil
}