mirror of
				https://github.com/axllent/goptimize.git
				synced 2025-11-03 19:38:20 -05:00 
			
		
		
		
	Compare commits
	
		
			72 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					49d804950f | ||
| 
						 | 
					a6ee641f81 | ||
| 
						 | 
					1234beaed0 | ||
| 
						 | 
					e44ce81125 | ||
| 
						 | 
					59301ffc1a | ||
| 
						 | 
					e795f587ef | ||
| 
						 | 
					d94f93af58 | ||
| 
						 | 
					065ff933c0 | ||
| 
						 | 
					42a5284e57 | ||
| 
						 | 
					3fde14166e | ||
| 
						 | 
					0eb5cab8f8 | ||
| 
						 | 
					a48750e9a1 | ||
| 
						 | 
					e21049c75d | ||
| 
						 | 
					b6ff2a7a5c | ||
| 
						 | 
					c6ccb45a25 | ||
| 
						 | 
					99756efb02 | ||
| 
						 | 
					118c5bc9ac | ||
| 
						 | 
					4d08aff7ec | ||
| 
						 | 
					788c6ecf23 | ||
| 
						 | 
					dedca9b41d | ||
| 
						 | 
					74cb212d22 | ||
| 
						 | 
					1a6942417b | ||
| 
						 | 
					2444204b32 | ||
| 
						 | 
					423ebc1494 | ||
| 
						 | 
					e5ecbe9bdf | ||
| 
						 | 
					cc2b9d6b88 | ||
| 
						 | 
					db2f3b8534 | ||
| 
						 | 
					b9e5afefd4 | ||
| 
						 | 
					e8f5085a11 | ||
| 
						 | 
					6956f5fb08 | ||
| 
						 | 
					e55c5de522 | ||
| 
						 | 
					75ca6b7017 | ||
| 
						 | 
					ea2d490686 | ||
| 
						 | 
					e8c7298c9c | ||
| 
						 | 
					bb18d90d0e | ||
| 
						 | 
					90240fef3b | ||
| 
						 | 
					2b1b34a4e0 | ||
| 
						 | 
					529ea6971c | ||
| 
						 | 
					4fa6fed8c9 | ||
| 
						 | 
					745bed1273 | ||
| 
						 | 
					d3cd263300 | ||
| 
						 | 
					88af2a0a0c | ||
| 
						 | 
					fbb22582d1 | ||
| 
						 | 
					adac20101d | ||
| 
						 | 
					521f3f24c1 | ||
| 
						 | 
					3f9f09880c | ||
| 
						 | 
					eadd535e4c | ||
| 
						 | 
					f9bf16d6c4 | ||
| 
						 | 
					6c1aabaa70 | ||
| 
						 | 
					8c3e478384 | ||
| 
						 | 
					7ffb944b0f | ||
| 
						 | 
					cb4bde8c4e | ||
| 
						 | 
					49d7b9b12f | ||
| 
						 | 
					fcdbf3e90b | ||
| 
						 | 
					5096e6fbf0 | ||
| 
						 | 
					d7d827fed7 | ||
| 
						 | 
					19cf54b633 | ||
| 
						 | 
					57b1d33129 | ||
| 
						 | 
					c239a54ead | ||
| 
						 | 
					e6296f4719 | ||
| 
						 | 
					ee6aa69087 | ||
| 
						 | 
					8362617920 | ||
| 
						 | 
					b157fcd72b | ||
| 
						 | 
					0c000fe262 | ||
| 
						 | 
					97fa6c94c5 | ||
| 
						 | 
					36e9b4c8fa | ||
| 
						 | 
					5df486c53e | ||
| 
						 | 
					120e5fefde | ||
| 
						 | 
					d2a591a6d9 | ||
| 
						 | 
					8e5e7b6098 | ||
| 
						 | 
					47024e030b | ||
| 
						 | 
					45915d4b42 | 
							
								
								
									
										15
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@@ -0,0 +1,15 @@
 | 
			
		||||
# To get started with Dependabot version updates, you'll need to specify which
 | 
			
		||||
# package ecosystems to update and where the package manifests are located.
 | 
			
		||||
# Please see the documentation for all configuration options:
 | 
			
		||||
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
 | 
			
		||||
 | 
			
		||||
version: 2
 | 
			
		||||
updates:
 | 
			
		||||
  - package-ecosystem: "gomod"
 | 
			
		||||
    directory: "/" # Location of package manifests
 | 
			
		||||
    schedule:
 | 
			
		||||
      interval: "quarterly"
 | 
			
		||||
  - package-ecosystem: "github-actions"
 | 
			
		||||
    directory: "/" # Location of package manifests
 | 
			
		||||
    schedule:
 | 
			
		||||
      interval: "quarterly"
 | 
			
		||||
							
								
								
									
										35
									
								
								.github/workflows/build-release.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								.github/workflows/build-release.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@@ -0,0 +1,35 @@
 | 
			
		||||
name: Release Go Binaries
 | 
			
		||||
 | 
			
		||||
on:
 | 
			
		||||
  release:
 | 
			
		||||
    types: [created]
 | 
			
		||||
 | 
			
		||||
jobs:
 | 
			
		||||
  releases-matrix:
 | 
			
		||||
    name: Release Go Binary
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    strategy:
 | 
			
		||||
      matrix:
 | 
			
		||||
        goos: [linux, darwin]
 | 
			
		||||
        goarch: ["386", amd64, arm, arm64]
 | 
			
		||||
        exclude:
 | 
			
		||||
          - goarch: "386"
 | 
			
		||||
            goos: darwin
 | 
			
		||||
          - goarch: arm
 | 
			
		||||
            goos: darwin
 | 
			
		||||
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: actions/checkout@v5
 | 
			
		||||
 | 
			
		||||
      - uses: wangyoucao577/go-release-action@v1
 | 
			
		||||
        with:
 | 
			
		||||
          binary_name: "goptimize"
 | 
			
		||||
          asset_name: "goptimize-${{ matrix.goos }}-${{ matrix.goarch }}"
 | 
			
		||||
          extra_files: LICENSE README.md
 | 
			
		||||
          github_token: ${{ secrets.GITHUB_TOKEN }}
 | 
			
		||||
          md5sum: false
 | 
			
		||||
          overwrite: true
 | 
			
		||||
          goos: ${{ matrix.goos }}
 | 
			
		||||
          goarch: ${{ matrix.goarch }}
 | 
			
		||||
          pre_command: export GO386=softfloat CGO_ENABLED=0
 | 
			
		||||
          ldflags: -s -w -X "main.version=${{ github.ref_name }}"
 | 
			
		||||
							
								
								
									
										72
									
								
								.github/workflows/codeql-analysis.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										72
									
								
								.github/workflows/codeql-analysis.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@@ -0,0 +1,72 @@
 | 
			
		||||
# For most projects, this workflow file will not need changing; you simply need
 | 
			
		||||
# to commit it to your repository.
 | 
			
		||||
#
 | 
			
		||||
# You may wish to alter this file to override the set of languages analyzed,
 | 
			
		||||
# or to provide custom queries or build logic.
 | 
			
		||||
#
 | 
			
		||||
# ******** NOTE ********
 | 
			
		||||
# We have attempted to detect the languages in your repository. Please check
 | 
			
		||||
# the `language` matrix defined below to confirm you have the correct set of
 | 
			
		||||
# supported CodeQL languages.
 | 
			
		||||
#
 | 
			
		||||
name: "CodeQL"
 | 
			
		||||
 | 
			
		||||
on:
 | 
			
		||||
  push:
 | 
			
		||||
    branches: [ "develop" ]
 | 
			
		||||
  pull_request:
 | 
			
		||||
    # The branches below must be a subset of the branches above
 | 
			
		||||
    branches: [ "develop" ]
 | 
			
		||||
  schedule:
 | 
			
		||||
    - cron: '34 23 * * 4'
 | 
			
		||||
 | 
			
		||||
jobs:
 | 
			
		||||
  analyze:
 | 
			
		||||
    name: Analyze
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    permissions:
 | 
			
		||||
      actions: read
 | 
			
		||||
      contents: read
 | 
			
		||||
      security-events: write
 | 
			
		||||
 | 
			
		||||
    strategy:
 | 
			
		||||
      fail-fast: false
 | 
			
		||||
      matrix:
 | 
			
		||||
        language: [ 'go' ]
 | 
			
		||||
        # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
 | 
			
		||||
        # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
 | 
			
		||||
 | 
			
		||||
    steps:
 | 
			
		||||
    - name: Checkout repository
 | 
			
		||||
      uses: actions/checkout@v5
 | 
			
		||||
 | 
			
		||||
    # Initializes the CodeQL tools for scanning.
 | 
			
		||||
    - name: Initialize CodeQL
 | 
			
		||||
      uses: github/codeql-action/init@v3
 | 
			
		||||
      with:
 | 
			
		||||
        languages: ${{ matrix.language }}
 | 
			
		||||
        # If you wish to specify custom queries, you can do so here or in a config file.
 | 
			
		||||
        # By default, queries listed here will override any specified in a config file.
 | 
			
		||||
        # Prefix the list here with "+" to use these queries and those in the config file.
 | 
			
		||||
        
 | 
			
		||||
        # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
 | 
			
		||||
        # queries: security-extended,security-and-quality
 | 
			
		||||
 | 
			
		||||
        
 | 
			
		||||
    # Autobuild attempts to build any compiled languages  (C/C++, C#, or Java).
 | 
			
		||||
    # If this step fails, then you should remove it and run the build manually (see below)
 | 
			
		||||
    - name: Autobuild
 | 
			
		||||
      uses: github/codeql-action/autobuild@v3
 | 
			
		||||
 | 
			
		||||
    # ℹ️ Command-line programs to run using the OS shell.
 | 
			
		||||
    # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
 | 
			
		||||
 | 
			
		||||
    #   If the Autobuild fails above, remove it and uncomment the following three lines. 
 | 
			
		||||
    #   modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
 | 
			
		||||
 | 
			
		||||
    # - run: |
 | 
			
		||||
    #   echo "Run, Build Application using script"
 | 
			
		||||
    #   ./location_of_script_within_repo/buildscript.sh
 | 
			
		||||
 | 
			
		||||
    - name: Perform CodeQL Analysis
 | 
			
		||||
      uses: github/codeql-action/analyze@v3
 | 
			
		||||
							
								
								
									
										20
									
								
								.github/workflows/tests.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								.github/workflows/tests.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@@ -0,0 +1,20 @@
 | 
			
		||||
name: Tests
 | 
			
		||||
on:
 | 
			
		||||
  pull_request:
 | 
			
		||||
    branches: [develop, "feature/**"]
 | 
			
		||||
  push:
 | 
			
		||||
    branches: [develop, "feature/**"]
 | 
			
		||||
 | 
			
		||||
jobs:
 | 
			
		||||
  golangci:
 | 
			
		||||
    name: golangci-lint
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: actions/checkout@v5
 | 
			
		||||
      - uses: actions/setup-go@v6
 | 
			
		||||
        with:
 | 
			
		||||
          go-version: stable
 | 
			
		||||
      - name: golangci-lint
 | 
			
		||||
        uses: golangci/golangci-lint-action@v8
 | 
			
		||||
        with:
 | 
			
		||||
          version: v2.1
 | 
			
		||||
							
								
								
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@@ -1,2 +1,2 @@
 | 
			
		||||
/dist/
 | 
			
		||||
goptimize
 | 
			
		||||
goptimize*
 | 
			
		||||
							
								
								
									
										39
									
								
								CHANGELOG.md
									
									
									
									
									
								
							
							
						
						
									
										39
									
								
								CHANGELOG.md
									
									
									
									
									
								
							@@ -1,5 +1,44 @@
 | 
			
		||||
# Changelog
 | 
			
		||||
 | 
			
		||||
## [1.0.0]
 | 
			
		||||
 | 
			
		||||
- Use ghru/v2 for latest version checks and self-update functionality
 | 
			
		||||
- Refactor code for consistency and error handling improvements
 | 
			
		||||
- Update Go dependencies
 | 
			
		||||
- Use GitHub Actions to build release binaries
 | 
			
		||||
- Add quarterly dependabot module update
 | 
			
		||||
- Add code quality checks & linting (golangci-lint)
 | 
			
		||||
 | 
			
		||||
## [0.2.3]
 | 
			
		||||
 | 
			
		||||
- Switch to kovidgoyal/imaging to address CVE-2023-36308
 | 
			
		||||
 | 
			
		||||
## [0.2.2]
 | 
			
		||||
 | 
			
		||||
- Optionally preserve exif data for supported formats
 | 
			
		||||
- Update Go modules
 | 
			
		||||
 | 
			
		||||
## [0.2.1]
 | 
			
		||||
 | 
			
		||||
- Update core modules
 | 
			
		||||
 | 
			
		||||
## [0.2.0]
 | 
			
		||||
 | 
			
		||||
- Add threaded option (`-t`) to use all CPU cores
 | 
			
		||||
 | 
			
		||||
## [0.1.0]
 | 
			
		||||
 | 
			
		||||
- Switch to go mods - go (>= 1.11 required)
 | 
			
		||||
- Switch to axllent/semver for app updating
 | 
			
		||||
 | 
			
		||||
## [0.0.3]
 | 
			
		||||
 | 
			
		||||
- Detect & skip animated GIFs
 | 
			
		||||
 | 
			
		||||
## [0.0.2]
 | 
			
		||||
 | 
			
		||||
- Switch to [pflag](https://github.com/spf13/pflag) for better flag management
 | 
			
		||||
 | 
			
		||||
## [0.0.1]
 | 
			
		||||
 | 
			
		||||
- Initial release
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										31
									
								
								LICENSE
									
									
									
									
									
								
							
							
						
						
									
										31
									
								
								LICENSE
									
									
									
									
									
								
							@@ -1,16 +1,21 @@
 | 
			
		||||
Copyright 2019 Ralph Slooten
 | 
			
		||||
The MIT License (MIT)
 | 
			
		||||
Copyright (c) 2019-Now() 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:
 | 
			
		||||
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 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.
 | 
			
		||||
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
									
									
									
									
									
								
							
							
						
						
									
										24
									
								
								Makefile
									
									
									
									
									
								
							@@ -1,24 +0,0 @@
 | 
			
		||||
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
									
									
									
									
									
								
							
							
						
						
									
										56
									
								
								README.md
									
									
									
									
									
								
							@@ -1,26 +1,27 @@
 | 
			
		||||
# Goptimizer - downscales and optimizes images
 | 
			
		||||
# Goptimize - downscales and optimizes images
 | 
			
		||||
 | 
			
		||||
Goptimizer is a commandline utility written in Golang. It downscales and optimize existing images JPEG, PNG and Gif files.
 | 
			
		||||
[](https://goreportcard.com/report/github.com/axllent/goptimize)
 | 
			
		||||
 | 
			
		||||
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):
 | 
			
		||||
Goptimize is a command-line utility written in Go. It downscales and optimizes JPEG, PNG, GIF, TIFF and BMP files.
 | 
			
		||||
 | 
			
		||||
- jpegoptim
 | 
			
		||||
- jpegtran (`libjpeg-turbo-progs`)
 | 
			
		||||
Image downscaling/rotation 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):
 | 
			
		||||
 | 
			
		||||
- jpegtran (`libjpeg-turbo-progs`) or jpegoptim
 | 
			
		||||
- 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.
 | 
			
		||||
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 can result in better optimization.
 | 
			
		||||
 | 
			
		||||
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.
 | 
			
		||||
GGoptimize will by default remove all EXIF data from JPEG files and automatically rotate those that rely on it for orientation. This can be disabled by adding the `-e` flag.
 | 
			
		||||
 | 
			
		||||
It will also preserve (by default) the file's original modification times (`-p=false` to disable).
 | 
			
		||||
 | 
			
		||||
Animated GIF files are not supported and are ignored.
 | 
			
		||||
 | 
			
		||||
## Usage options
 | 
			
		||||
 | 
			
		||||
@@ -28,29 +29,32 @@ It will also preserve (by default) the file's original modification times (`-p=f
 | 
			
		||||
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)
 | 
			
		||||
  -q, --quality int        quality, JPEG only (default 75)
 | 
			
		||||
  -m, --max string         downscale to a maximum width & height in pixels (<width>x<height>)
 | 
			
		||||
  -o, --out string         output directory (default overwrites original)
 | 
			
		||||
  -p, --preserve           preserve file modification times (default true)
 | 
			
		||||
  -t, --threaded           run multi-threaded (use all CPU cores)
 | 
			
		||||
  -u, --update             update to latest release
 | 
			
		||||
  -v, --version            show version number
 | 
			
		||||
  -h, --help               show help
 | 
			
		||||
      --jpegtran string    jpegtran binary (default "jpegtran")
 | 
			
		||||
      --jpegoptim string   jpegoptim binary (default "jpegoptim")
 | 
			
		||||
      --gifsicle string    gifsicle binary (default "gifsicle")
 | 
			
		||||
      --pngquant string    pngquant binary (default "pngquant")
 | 
			
		||||
      --optipng string     optipng binary (default "optipng")
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
## 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/`
 | 
			
		||||
 | 
			
		||||
## Install
 | 
			
		||||
 | 
			
		||||
Download the appropriate binary from the [releases](https://github.com/axllent/goptimize/releases/latest), or if you have golang installed you can install from source:
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
go install github.com/axllent/goptimize@latest
 | 
			
		||||
```
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										171
									
								
								exifcopy.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										171
									
								
								exifcopy.go
									
									
									
									
									
										Normal file
									
								
							@@ -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 func() { _ = outFile.Close() }()
 | 
			
		||||
	writer := bufio.NewWriter(outFile)
 | 
			
		||||
 | 
			
		||||
	imageFile, err := os.Open(imagePath)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
	defer func() { _ = imageFile.Close() }()
 | 
			
		||||
	imageReader := bufio.NewReader(imageFile)
 | 
			
		||||
 | 
			
		||||
	metaFile, err := os.Open(metadataImagePath)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
	defer func() { _ = 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 func() { _ = os.Remove(copyPath) }()
 | 
			
		||||
	return copyMetadata(toPath, copyPath, fromPath)
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										12
									
								
								go.mod
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								go.mod
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,12 @@
 | 
			
		||||
module github.com/axllent/goptimize
 | 
			
		||||
 | 
			
		||||
go 1.24.0
 | 
			
		||||
 | 
			
		||||
require (
 | 
			
		||||
	github.com/axllent/ghru/v2 v2.0.2
 | 
			
		||||
	github.com/kovidgoyal/imaging v1.6.4
 | 
			
		||||
	github.com/spf13/pflag v1.0.10
 | 
			
		||||
	golang.org/x/image v0.31.0
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
require golang.org/x/mod v0.28.0 // indirect
 | 
			
		||||
							
								
								
									
										10
									
								
								go.sum
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								go.sum
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,10 @@
 | 
			
		||||
github.com/axllent/ghru/v2 v2.0.2 h1:xalJupjJAU8Kcs39AwpG53qbcbi3+WKM98BEoQWf/zU=
 | 
			
		||||
github.com/axllent/ghru/v2 v2.0.2/go.mod h1:seMMjx8/0r5ZAL7c0vwTPIRoyN0AoTUqAylZEWZWGK4=
 | 
			
		||||
github.com/kovidgoyal/imaging v1.6.4 h1:K0idhRPXnRrJBKnBYcTfI1HTWSNDeAn7hYDvf9I0dCk=
 | 
			
		||||
github.com/kovidgoyal/imaging v1.6.4/go.mod h1:bEIgsaZmXlvFfkv/CUxr9rJook6AQkJnpB5EPosRfRY=
 | 
			
		||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
 | 
			
		||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
 | 
			
		||||
golang.org/x/image v0.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA=
 | 
			
		||||
golang.org/x/image v0.31.0/go.mod h1:R9ec5Lcp96v9FTF+ajwaH3uGxPH4fKfHHAVbUILxghA=
 | 
			
		||||
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
 | 
			
		||||
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
 | 
			
		||||
							
								
								
									
										153
									
								
								goptimize.go
									
									
									
									
									
								
							
							
						
						
									
										153
									
								
								goptimize.go
									
									
									
									
									
								
							@@ -2,51 +2,67 @@ package main
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"image"
 | 
			
		||||
	"image/gif"
 | 
			
		||||
	"image/jpeg"
 | 
			
		||||
	"image/png"
 | 
			
		||||
	"io"
 | 
			
		||||
	"io/ioutil"
 | 
			
		||||
	"math"
 | 
			
		||||
	"os"
 | 
			
		||||
	"os/exec"
 | 
			
		||||
	"path/filepath"
 | 
			
		||||
 | 
			
		||||
	"github.com/disintegration/imaging"
 | 
			
		||||
	"github.com/kovidgoyal/imaging"
 | 
			
		||||
	"golang.org/x/image/bmp"
 | 
			
		||||
	"golang.org/x/image/tiff"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// Goptimize downscales and optimizes an existing image
 | 
			
		||||
func Goptimize(file string) {
 | 
			
		||||
func goptimize(file string) {
 | 
			
		||||
	info, err := os.Stat(file)
 | 
			
		||||
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		fmt.Printf("%s doesn't exist\n", file)
 | 
			
		||||
		fmt.Printf("Error: %s doesn't exist\n", file)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if !info.Mode().IsRegular() {
 | 
			
		||||
		// not a file
 | 
			
		||||
		fmt.Printf("%s is not a file\n", file)
 | 
			
		||||
		fmt.Printf("Error: %s is not a file\n", file)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// open original, rotate if neccesary
 | 
			
		||||
	src, err := imaging.Open(file, imaging.AutoOrientation(true))
 | 
			
		||||
	var src image.Image
 | 
			
		||||
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		fmt.Printf("%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)
 | 
			
		||||
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		fmt.Printf("Cannot detect format: %v\n", err)
 | 
			
		||||
		fmt.Printf("Error: cannot detect format: %v\n", err)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if format.String() == "GIF" {
 | 
			
		||||
		// return if GIF is animated - unsupported
 | 
			
		||||
		if err := isGIFAnimated(file); err != nil {
 | 
			
		||||
			fmt.Printf("Error: animated GIF not supported (%v)\n", file)
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	outFilename := filepath.Base(file)
 | 
			
		||||
	outDir := filepath.Dir(file)
 | 
			
		||||
	dstFile := filepath.Join(outDir, outFilename)
 | 
			
		||||
@@ -60,7 +76,7 @@ func Goptimize(file string) {
 | 
			
		||||
	srcW := srcBounds.Dx()
 | 
			
		||||
	srcH := srcBounds.Dy()
 | 
			
		||||
 | 
			
		||||
	// Ensure scaling does not upscale image
 | 
			
		||||
	// do not upscale image
 | 
			
		||||
	imgMaxW := maxWidth
 | 
			
		||||
	if imgMaxW == 0 || imgMaxW > srcW {
 | 
			
		||||
		imgMaxW = srcW
 | 
			
		||||
@@ -76,27 +92,28 @@ 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("Cannot create temporary file: %v\n", err)
 | 
			
		||||
		fmt.Printf("Error: cannot create temporary file: %v\n", err)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	defer os.Remove(tmpFile.Name())
 | 
			
		||||
	defer func() { _ = os.Remove(tmpFile.Name()) }()
 | 
			
		||||
 | 
			
		||||
	if format.String() == "JPEG" {
 | 
			
		||||
	switch imgType := format.String(); imgType {
 | 
			
		||||
	case "JPEG":
 | 
			
		||||
		err = jpeg.Encode(tmpFile, resized, &jpeg.Options{Quality: quality})
 | 
			
		||||
	} else if format.String() == "PNG" {
 | 
			
		||||
	case "PNG":
 | 
			
		||||
		err = png.Encode(tmpFile, resized)
 | 
			
		||||
	} else if format.String() == "GIF" {
 | 
			
		||||
	case "GIF":
 | 
			
		||||
		err = gif.Encode(tmpFile, resized, nil)
 | 
			
		||||
	} else if format.String() == "TIFF" {
 | 
			
		||||
	case "TIFF":
 | 
			
		||||
		err = tiff.Encode(tmpFile, resized, nil)
 | 
			
		||||
	} else if format.String() == "BMP" {
 | 
			
		||||
	case "BMP":
 | 
			
		||||
		err = bmp.Encode(tmpFile, resized)
 | 
			
		||||
	} else {
 | 
			
		||||
		fmt.Printf("Unsupported file type %s\n", file)
 | 
			
		||||
	default:
 | 
			
		||||
		fmt.Printf("Error: unsupported file type (%s)\n", file)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@@ -110,25 +127,35 @@ func Goptimize(file string) {
 | 
			
		||||
 | 
			
		||||
	// immediately close the temp file to release pointers
 | 
			
		||||
	// so we can modify it with system processes
 | 
			
		||||
	tmpFile.Close()
 | 
			
		||||
	if err := tmpFile.Close(); err != nil {
 | 
			
		||||
		fmt.Printf("Error closing temporary file: %v\n", err)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Run through optimizers
 | 
			
		||||
	// 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")
 | 
			
		||||
			runOptimizer(tmpFilename, true, jpegtran, "-optimize", "-outfile")
 | 
			
		||||
		} else if jpegoptim != "" {
 | 
			
		||||
			RunOptimiser(tmpFilename, false, jpegoptim, "-f", "-s", "-o")
 | 
			
		||||
			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 != "" {
 | 
			
		||||
			RunOptimiser(tmpFilename, true, pngquant, "-f", "--output")
 | 
			
		||||
			runOptimizer(tmpFilename, true, pngquant, "-f", "--output")
 | 
			
		||||
		}
 | 
			
		||||
		if optipng != "" {
 | 
			
		||||
			RunOptimiser(tmpFilename, true, optipng, "-out")
 | 
			
		||||
			runOptimizer(tmpFilename, true, optipng, "-out")
 | 
			
		||||
		}
 | 
			
		||||
	} else if format.String() == "GIF" && gifsicle != "" {
 | 
			
		||||
		RunOptimiser(tmpFilename, true, gifsicle, "-o")
 | 
			
		||||
		runOptimizer(tmpFilename, true, gifsicle, "-o")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// re-open modified temporary file
 | 
			
		||||
@@ -138,7 +165,7 @@ func Goptimize(file string) {
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	defer tmpFile.Close()
 | 
			
		||||
	defer func() { _ = tmpFile.Close() }()
 | 
			
		||||
 | 
			
		||||
	// get th eoriginal file stats
 | 
			
		||||
	srcStat, _ := os.Stat(file)
 | 
			
		||||
@@ -163,10 +190,10 @@ func Goptimize(file string) {
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		defer out.Close()
 | 
			
		||||
		defer func() { _ = out.Close() }()
 | 
			
		||||
 | 
			
		||||
		if _, err := io.Copy(out, tmpFile); err != nil {
 | 
			
		||||
			fmt.Printf("Error ovewriting original file: %v\n", err)
 | 
			
		||||
			fmt.Printf("Error overwriting original file: %v\n", err)
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
@@ -177,22 +204,26 @@ func Goptimize(file string) {
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		fmt.Printf("Goptimized %s (%dx%d %s > %s %v%%)\n", dstFile, resultW, resultH, ByteCountSI(srcSize), ByteCountSI(dstSize), savedPercent)
 | 
			
		||||
		fmt.Printf("Optimized %s (%dx%d %s > %s %v%%)\n", dstFile, resultW, resultH, byteCountSI(srcSize), byteCountSI(dstSize), savedPercent)
 | 
			
		||||
	} else {
 | 
			
		||||
		// If the output directory is not the same,
 | 
			
		||||
		// 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 creating new file: %v\n", err)
 | 
			
		||||
				return
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			defer func() { _ = out.Close() }()
 | 
			
		||||
 | 
			
		||||
			orig, err := os.Open(file)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				fmt.Printf("Error opening original file: %v\n", err)
 | 
			
		||||
				return
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			defer out.Close()
 | 
			
		||||
 | 
			
		||||
			orig, _ := os.Open(file)
 | 
			
		||||
 | 
			
		||||
			defer orig.Close()
 | 
			
		||||
			defer func() { _ = orig.Close() }()
 | 
			
		||||
 | 
			
		||||
			if _, err := io.Copy(out, orig); err != nil {
 | 
			
		||||
				fmt.Printf("Error ovewriting original file: %v\n", err)
 | 
			
		||||
@@ -206,36 +237,35 @@ func Goptimize(file string) {
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			fmt.Printf("Copied %s (%dx%d %s %v%%)\n", dstFile, srcW, srcH, 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 %v%%)\n", dstFile, srcW, srcH, ByteCountSI(srcSize), 0)
 | 
			
		||||
			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,
 | 
			
		||||
// runOptimizer 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) {
 | 
			
		||||
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(), "goptimize-")
 | 
			
		||||
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		fmt.Printf("Cannot create temporary file: %v\n", err)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	defer os.Remove(tmpFile.Name())
 | 
			
		||||
	defer func() { _ = 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()
 | 
			
		||||
	defer func() { _ = source.Close() }()
 | 
			
		||||
 | 
			
		||||
	if _, err := io.Copy(tmpFile, source); err != nil {
 | 
			
		||||
		fmt.Printf("Cannot copy source file: %v\n", err)
 | 
			
		||||
@@ -265,8 +295,8 @@ func RunOptimiser(src string, outFileArg bool, args ...string) {
 | 
			
		||||
	dstSize := dstStat.Size()
 | 
			
		||||
 | 
			
		||||
	// ensure file pointers are closed before renaming
 | 
			
		||||
	tmpFile.Close()
 | 
			
		||||
	source.Close()
 | 
			
		||||
	func() { _ = tmpFile.Close() }()
 | 
			
		||||
	func() { _ = source.Close() }()
 | 
			
		||||
 | 
			
		||||
	if dstSize < srcSize {
 | 
			
		||||
		if err := os.Rename(tmpFilename, src); err != nil {
 | 
			
		||||
@@ -277,7 +307,7 @@ func RunOptimiser(src string, outFileArg bool, args ...string) {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// ByteCountSI returns a human readable size from int64 bytes
 | 
			
		||||
func ByteCountSI(b int64) string {
 | 
			
		||||
func byteCountSI(b int64) string {
 | 
			
		||||
	const unit = 1000
 | 
			
		||||
	if b < unit {
 | 
			
		||||
		return fmt.Sprintf("%dB", b)
 | 
			
		||||
@@ -289,3 +319,24 @@ func ByteCountSI(b int64) string {
 | 
			
		||||
	}
 | 
			
		||||
	return fmt.Sprintf("%.1f%cB", float64(b)/float64(div), "kMGTPE"[exp])
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// IsGIFAnimated will return an error if the GIF file has more than 1 frame
 | 
			
		||||
func isGIFAnimated(gifFile string) error {
 | 
			
		||||
	file, err := os.Open(gifFile)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return fmt.Errorf("cannot open GIF file: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
	defer func() { _ = file.Close() }()
 | 
			
		||||
 | 
			
		||||
	g, err := gif.DecodeAll(file)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Single frame = OK
 | 
			
		||||
	if len(g.Image) == 1 {
 | 
			
		||||
		return nil
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return fmt.Errorf("cannot optimize an animated gif")
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										135
									
								
								main.go
									
									
									
									
									
								
							
							
						
						
									
										135
									
								
								main.go
									
									
									
									
									
								
							@@ -1,14 +1,17 @@
 | 
			
		||||
// Package main is the main application
 | 
			
		||||
package main
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"flag"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"os"
 | 
			
		||||
	"os/exec"
 | 
			
		||||
	"regexp"
 | 
			
		||||
	"runtime"
 | 
			
		||||
	"strconv"
 | 
			
		||||
	"sync"
 | 
			
		||||
 | 
			
		||||
	"github.com/axllent/gitrel"
 | 
			
		||||
	"github.com/axllent/ghru/v2"
 | 
			
		||||
	"github.com/spf13/pflag"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
var (
 | 
			
		||||
@@ -22,15 +25,28 @@ var (
 | 
			
		||||
	optipng          string
 | 
			
		||||
	pngquant         string
 | 
			
		||||
	gifsicle         string
 | 
			
		||||
	copyExif         bool
 | 
			
		||||
	threads          = 1
 | 
			
		||||
	version          = "dev"
 | 
			
		||||
	// ghruConf is the configuration for the ghru package
 | 
			
		||||
	ghruConf = ghru.Config{
 | 
			
		||||
		Repo:           "axllent/goptimize",
 | 
			
		||||
		ArchiveName:    "goptimize-{{.OS}}-{{.Arch}}",
 | 
			
		||||
		BinaryName:     "goptimize",
 | 
			
		||||
		CurrentVersion: version,
 | 
			
		||||
	}
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func main() {
 | 
			
		||||
	// set up new flag instance
 | 
			
		||||
	flag := pflag.NewFlagSet(os.Args[0], pflag.ExitOnError)
 | 
			
		||||
 | 
			
		||||
	// 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.SortFlags = false
 | 
			
		||||
		flag.PrintDefaults()
 | 
			
		||||
		fmt.Println("\nExamples:")
 | 
			
		||||
		fmt.Printf("  %s image.png\n", os.Args[0])
 | 
			
		||||
@@ -38,33 +54,40 @@ func main() {
 | 
			
		||||
		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)
 | 
			
		||||
		if err := displayDetectedOptimizer("jpegtran ", jpegtran); err != nil {
 | 
			
		||||
			_ = displayDetectedOptimizer("jpegoptim", jpegoptim)
 | 
			
		||||
		}
 | 
			
		||||
		displayDelectedOptimizer("optipng  ", optipng)
 | 
			
		||||
		displayDelectedOptimizer("pngquant ", pngquant)
 | 
			
		||||
		displayDelectedOptimizer("gifsicle ", gifsicle)
 | 
			
		||||
		_ = displayDetectedOptimizer("optipng  ", optipng)
 | 
			
		||||
		_ = displayDetectedOptimizer("pngquant ", pngquant)
 | 
			
		||||
		_ = displayDetectedOptimizer("gifsicle ", gifsicle)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var maxSizes string
 | 
			
		||||
	var update, showversion bool
 | 
			
		||||
	var multiThreaded, update, showVersion, showHelp 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")
 | 
			
		||||
	flag.IntVarP(&quality, "quality", "q", 75, "quality, JPEG only")
 | 
			
		||||
	flag.StringVarP(&maxSizes, "max", "m", "", "downscale to a maximum width & height in pixels (<width>x<height>)")
 | 
			
		||||
	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")
 | 
			
		||||
	flag.BoolVarP(&showHelp, "help", "h", false, "show help")
 | 
			
		||||
 | 
			
		||||
	// 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(&jpegoptim, "jpegoptim", "jpegoptim", "jpegoptim binary")
 | 
			
		||||
	flag.StringVar(&gifsicle, "gifsicle", "gifsicle", "gifsicle binary")
 | 
			
		||||
	flag.StringVar(&pngquant, "pngquant", "pngquant", "pngquant binary")
 | 
			
		||||
	flag.StringVar(&optipng, "optipng", "optipng", "optipng binary")
 | 
			
		||||
 | 
			
		||||
	// parse flags
 | 
			
		||||
	flag.Parse()
 | 
			
		||||
	flag.SortFlags = false
 | 
			
		||||
 | 
			
		||||
	// parse args excluding os.Args[0]
 | 
			
		||||
	if err := flag.Parse(os.Args[1:]); err != nil {
 | 
			
		||||
		fmt.Printf("Error parsing flags: %s\n", err.Error())
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// detect optimizer paths
 | 
			
		||||
	gifsicle, _ = exec.LookPath(gifsicle)
 | 
			
		||||
@@ -73,23 +96,44 @@ 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])
 | 
			
		||||
	if showHelp {
 | 
			
		||||
		flag.Usage()
 | 
			
		||||
		os.Exit(1)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if showVersion {
 | 
			
		||||
		fmt.Printf("Version: %s\n", version)
 | 
			
		||||
 | 
			
		||||
		release, err := ghruConf.Latest()
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			fmt.Printf("Error getting latest release: %s\n", err.Error())
 | 
			
		||||
			os.Exit(1)
 | 
			
		||||
		}
 | 
			
		||||
		return
 | 
			
		||||
 | 
			
		||||
		// The latest version is the same version
 | 
			
		||||
		if release.Tag == version {
 | 
			
		||||
			os.Exit(0)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// A newer release is available
 | 
			
		||||
		fmt.Printf(
 | 
			
		||||
			"Update available: %s\nRun `%s -u` to update (requires read/write access to install directory).\n",
 | 
			
		||||
			release.Tag,
 | 
			
		||||
			os.Args[0],
 | 
			
		||||
		)
 | 
			
		||||
		os.Exit(0)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if update {
 | 
			
		||||
		rel, err := gitrel.Update("axllent/goptimize", "goptimize", version)
 | 
			
		||||
		// Update the app
 | 
			
		||||
		rel, err := ghruConf.SelfUpdate()
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			fmt.Println(err)
 | 
			
		||||
			fmt.Println(err.Error())
 | 
			
		||||
			os.Exit(1)
 | 
			
		||||
		}
 | 
			
		||||
		fmt.Printf("Updated %s to version %s", os.Args[0], rel)
 | 
			
		||||
		return
 | 
			
		||||
 | 
			
		||||
		fmt.Printf("Updated %s to version %s\n", os.Args[0], rel.Tag)
 | 
			
		||||
		os.Exit(0)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if len(flag.Args()) < 1 {
 | 
			
		||||
@@ -125,13 +169,38 @@ func main() {
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for _, img := range args {
 | 
			
		||||
		Goptimize(img)
 | 
			
		||||
	if multiThreaded {
 | 
			
		||||
		threads = runtime.NumCPU()
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	processChan := make(chan string)
 | 
			
		||||
 | 
			
		||||
	wg := &sync.WaitGroup{} // Signal to main goroutine that worker goroutines are working/done working
 | 
			
		||||
	wg.Add(threads)
 | 
			
		||||
 | 
			
		||||
	for i := 0; i < threads; i++ {
 | 
			
		||||
		go func() {
 | 
			
		||||
			for nextFile := range processChan {
 | 
			
		||||
				goptimize(nextFile)
 | 
			
		||||
			}
 | 
			
		||||
			// Channel was closed, so we finished this goroutine.
 | 
			
		||||
			wg.Done() // Let main goroutine know we are done.
 | 
			
		||||
		}()
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for _, img := range args {
 | 
			
		||||
		processChan <- img
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Close the channel.  This tells the worker goroutines that they can be done.
 | 
			
		||||
	close(processChan)
 | 
			
		||||
 | 
			
		||||
	// Wait for all worker goroutines to finish processing the IPs
 | 
			
		||||
	wg.Wait()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// displayDelectedOptimizer prints whether the optimizer was found
 | 
			
		||||
func displayDelectedOptimizer(name, bin string) error {
 | 
			
		||||
// displayDetectedOptimizer prints whether the optimizer was found
 | 
			
		||||
func displayDetectedOptimizer(name, bin string) error {
 | 
			
		||||
	exe, err := exec.LookPath(bin)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user