mirror of
				https://github.com/axllent/goptimize.git
				synced 2025-10-30 18:17:25 -04: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/ | /dist/ | ||||||
| goptimize | goptimize* | ||||||
							
								
								
									
										39
									
								
								CHANGELOG.md
									
									
									
									
									
								
							
							
						
						
									
										39
									
								
								CHANGELOG.md
									
									
									
									
									
								
							| @@ -1,5 +1,44 @@ | |||||||
| # Changelog | # 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] | ## [0.0.1] | ||||||
|  |  | ||||||
| - Initial release | - 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 | Permission is hereby granted, free of charge, to any person obtaining | ||||||
| and associated documentation files (the "Software"), to deal in the Software without restriction, | a copy of this software and associated documentation files (the | ||||||
| including without limitation the rights to use, copy, modify, merge, publish, distribute, | "Software"), to deal in the Software without restriction, including | ||||||
| sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is | without limitation the rights to use, copy, modify, merge, publish, | ||||||
| furnished to do so, subject to the following conditions: | 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 | The above copyright notice and this permission notice shall be | ||||||
| substantial portions of the Software. | 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 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, | ||||||
| BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF | ||||||
| NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND | ||||||
| DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE | ||||||
| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. | 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 | 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`) |  | ||||||
|  | - jpegtran (`libjpeg-turbo-progs`) or jpegoptim | ||||||
| - optipng | - optipng | ||||||
| - pngquant | - pngquant | ||||||
| - gifsicle | - gifsicle | ||||||
|  |  | ||||||
|  |  | ||||||
| ## Notes | ## 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. | 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). | 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 | ## Usage options | ||||||
|  |  | ||||||
| @@ -28,29 +29,32 @@ It will also preserve (by default) the file's original modification times (`-p=f | |||||||
| Usage: ./goptimize [options] <images> | Usage: ./goptimize [options] <images> | ||||||
|  |  | ||||||
| Options: | Options: | ||||||
|   -gifsicle string |   -q, --quality int        quality, JPEG only (default 75) | ||||||
|         gifsicle binary (default "gifsicle") |   -m, --max string         downscale to a maximum width & height in pixels (<width>x<height>) | ||||||
|   -jpegoptim string |   -o, --out string         output directory (default overwrites original) | ||||||
|         jpegoptim binary (default "jpegoptim") |   -p, --preserve           preserve file modification times (default true) | ||||||
|   -jpegtran string |   -t, --threaded           run multi-threaded (use all CPU cores) | ||||||
|         jpegtran binary (default "jpegtran") |   -u, --update             update to latest release | ||||||
|   -m string |   -v, --version            show version number | ||||||
|         downscale to a maximum width & height in pixels (<width>x<height>) |   -h, --help               show help | ||||||
|   -o string |       --jpegtran string    jpegtran binary (default "jpegtran") | ||||||
|         output directory (default overwrites original) |       --jpegoptim string   jpegoptim binary (default "jpegoptim") | ||||||
|   -optipng string |       --gifsicle string    gifsicle binary (default "gifsicle") | ||||||
|         optipng binary (default "optipng") |       --pngquant string    pngquant binary (default "pngquant") | ||||||
|   -p    preserve file modification times (default true) |       --optipng string     optipng binary (default "optipng") | ||||||
|   -pngquant string |  | ||||||
|         pngquant binary (default "pngquant") |  | ||||||
|   -q int |  | ||||||
|         quality - JPEG only (default 75) |  | ||||||
| ``` | ``` | ||||||
|  |  | ||||||
|  |  | ||||||
| ## Examples | ## Examples | ||||||
|  |  | ||||||
| - `./goptimize image.png` - optimize a PNG file | - `./goptimize image.png` - optimize a PNG file | ||||||
| - `./goptimize -m 800x800 *` - optimize and downscale all image files to a maximum size of 800x800px | - `./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 -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/` | - `./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= | ||||||
							
								
								
									
										149
									
								
								goptimize.go
									
									
									
									
									
								
							
							
						
						
									
										149
									
								
								goptimize.go
									
									
									
									
									
								
							| @@ -2,51 +2,67 @@ package main | |||||||
|  |  | ||||||
| import ( | import ( | ||||||
| 	"fmt" | 	"fmt" | ||||||
|  | 	"image" | ||||||
| 	"image/gif" | 	"image/gif" | ||||||
| 	"image/jpeg" | 	"image/jpeg" | ||||||
| 	"image/png" | 	"image/png" | ||||||
| 	"io" | 	"io" | ||||||
| 	"io/ioutil" |  | ||||||
| 	"math" | 	"math" | ||||||
| 	"os" | 	"os" | ||||||
| 	"os/exec" | 	"os/exec" | ||||||
| 	"path/filepath" | 	"path/filepath" | ||||||
|  |  | ||||||
| 	"github.com/disintegration/imaging" | 	"github.com/kovidgoyal/imaging" | ||||||
| 	"golang.org/x/image/bmp" | 	"golang.org/x/image/bmp" | ||||||
| 	"golang.org/x/image/tiff" | 	"golang.org/x/image/tiff" | ||||||
| ) | ) | ||||||
|  |  | ||||||
| // Goptimize downscales and optimizes an existing image | // Goptimize downscales and optimizes an existing image | ||||||
| func Goptimize(file string) { | func goptimize(file string) { | ||||||
| 	info, err := os.Stat(file) | 	info, err := os.Stat(file) | ||||||
|  |  | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		fmt.Printf("%s doesn't exist\n", file) | 		fmt.Printf("Error: %s doesn't exist\n", file) | ||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	if !info.Mode().IsRegular() { | 	if !info.Mode().IsRegular() { | ||||||
| 		// not a file | 		fmt.Printf("Error: %s is not a file\n", file) | ||||||
| 		fmt.Printf("%s is not a file\n", file) |  | ||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	// open original, rotate if neccesary | 	var src image.Image | ||||||
| 	src, err := imaging.Open(file, imaging.AutoOrientation(true)) |  | ||||||
|  |  | ||||||
|  | 	if !copyExif { | ||||||
|  | 		// rotate if necessary | ||||||
|  | 		src, err = imaging.Open(file, imaging.AutoOrientation(true)) | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 		fmt.Printf("%v (%s)\n", err, file) | 			fmt.Printf("Error: %v (%s)\n", err, file) | ||||||
| 			return | 			return | ||||||
| 		} | 		} | ||||||
|  | 	} else { | ||||||
|  | 		src, err = imaging.Open(file) | ||||||
|  | 		if err != nil { | ||||||
|  | 			fmt.Printf("Error: %v (%s)\n", err, file) | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
| 	format, err := imaging.FormatFromFilename(file) | 	format, err := imaging.FormatFromFilename(file) | ||||||
|  |  | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		fmt.Printf("Cannot detect format: %v\n", err) | 		fmt.Printf("Error: cannot detect format: %v\n", err) | ||||||
| 		return | 		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) | 	outFilename := filepath.Base(file) | ||||||
| 	outDir := filepath.Dir(file) | 	outDir := filepath.Dir(file) | ||||||
| 	dstFile := filepath.Join(outDir, outFilename) | 	dstFile := filepath.Join(outDir, outFilename) | ||||||
| @@ -60,7 +76,7 @@ func Goptimize(file string) { | |||||||
| 	srcW := srcBounds.Dx() | 	srcW := srcBounds.Dx() | ||||||
| 	srcH := srcBounds.Dy() | 	srcH := srcBounds.Dy() | ||||||
|  |  | ||||||
| 	// Ensure scaling does not upscale image | 	// do not upscale image | ||||||
| 	imgMaxW := maxWidth | 	imgMaxW := maxWidth | ||||||
| 	if imgMaxW == 0 || imgMaxW > srcW { | 	if imgMaxW == 0 || imgMaxW > srcW { | ||||||
| 		imgMaxW = srcW | 		imgMaxW = srcW | ||||||
| @@ -76,27 +92,28 @@ func Goptimize(file string) { | |||||||
| 	resultW := dstBounds.Dx() | 	resultW := dstBounds.Dx() | ||||||
| 	resultH := dstBounds.Dy() | 	resultH := dstBounds.Dy() | ||||||
|  |  | ||||||
| 	tmpFile, err := ioutil.TempFile(os.TempDir(), "Goptimized-") | 	tmpFile, err := os.CreateTemp(os.TempDir(), "Goptimized-") | ||||||
|  |  | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		fmt.Printf("Cannot create temporary file: %v\n", err) | 		fmt.Printf("Error: cannot create temporary file: %v\n", err) | ||||||
| 		return | 		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}) | 		err = jpeg.Encode(tmpFile, resized, &jpeg.Options{Quality: quality}) | ||||||
| 	} else if format.String() == "PNG" { | 	case "PNG": | ||||||
| 		err = png.Encode(tmpFile, resized) | 		err = png.Encode(tmpFile, resized) | ||||||
| 	} else if format.String() == "GIF" { | 	case "GIF": | ||||||
| 		err = gif.Encode(tmpFile, resized, nil) | 		err = gif.Encode(tmpFile, resized, nil) | ||||||
| 	} else if format.String() == "TIFF" { | 	case "TIFF": | ||||||
| 		err = tiff.Encode(tmpFile, resized, nil) | 		err = tiff.Encode(tmpFile, resized, nil) | ||||||
| 	} else if format.String() == "BMP" { | 	case "BMP": | ||||||
| 		err = bmp.Encode(tmpFile, resized) | 		err = bmp.Encode(tmpFile, resized) | ||||||
| 	} else { | 	default: | ||||||
| 		fmt.Printf("Unsupported file type %s\n", file) | 		fmt.Printf("Error: unsupported file type (%s)\n", file) | ||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| @@ -110,25 +127,35 @@ func Goptimize(file string) { | |||||||
|  |  | ||||||
| 	// immediately close the temp file to release pointers | 	// immediately close the temp file to release pointers | ||||||
| 	// so we can modify it with system processes | 	// 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" { | 	if format.String() == "JPEG" { | ||||||
| 		// run one or the other, running both has no advantage | 		// run one or the other, running both has no advantage | ||||||
| 		if jpegtran != "" { | 		if jpegtran != "" { | ||||||
| 			RunOptimiser(tmpFilename, true, jpegtran, "-optimize", "-outfile") | 			runOptimizer(tmpFilename, true, jpegtran, "-optimize", "-outfile") | ||||||
| 		} else if jpegoptim != "" { | 		} 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" { | 	} else if format.String() == "PNG" { | ||||||
| 		if pngquant != "" { | 		if pngquant != "" { | ||||||
| 			RunOptimiser(tmpFilename, true, pngquant, "-f", "--output") | 			runOptimizer(tmpFilename, true, pngquant, "-f", "--output") | ||||||
| 		} | 		} | ||||||
| 		if optipng != "" { | 		if optipng != "" { | ||||||
| 			RunOptimiser(tmpFilename, true, optipng, "-out") | 			runOptimizer(tmpFilename, true, optipng, "-out") | ||||||
| 		} | 		} | ||||||
| 	} else if format.String() == "GIF" && gifsicle != "" { | 	} else if format.String() == "GIF" && gifsicle != "" { | ||||||
| 		RunOptimiser(tmpFilename, true, gifsicle, "-o") | 		runOptimizer(tmpFilename, true, gifsicle, "-o") | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	// re-open modified temporary file | 	// re-open modified temporary file | ||||||
| @@ -138,7 +165,7 @@ func Goptimize(file string) { | |||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	defer tmpFile.Close() | 	defer func() { _ = tmpFile.Close() }() | ||||||
|  |  | ||||||
| 	// get th eoriginal file stats | 	// get th eoriginal file stats | ||||||
| 	srcStat, _ := os.Stat(file) | 	srcStat, _ := os.Stat(file) | ||||||
| @@ -163,10 +190,10 @@ func Goptimize(file string) { | |||||||
| 			return | 			return | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		defer out.Close() | 		defer func() { _ = out.Close() }() | ||||||
|  |  | ||||||
| 		if _, err := io.Copy(out, tmpFile); err != nil { | 		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 | 			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 { | 	} 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 | 		// then write a copy of the original file | ||||||
| 		if outputDir != "" { | 		if outputDir != "" { | ||||||
| 			out, err := os.Create(dstFile) | 			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 { | 			if err != nil { | ||||||
| 				fmt.Printf("Error opening original file: %v\n", err) | 				fmt.Printf("Error opening original file: %v\n", err) | ||||||
| 				return | 				return | ||||||
| 			} | 			} | ||||||
|  |  | ||||||
| 			defer out.Close() | 			defer func() { _ = orig.Close() }() | ||||||
|  |  | ||||||
| 			orig, _ := os.Open(file) |  | ||||||
|  |  | ||||||
| 			defer orig.Close() |  | ||||||
|  |  | ||||||
| 			if _, err := io.Copy(out, orig); err != nil { | 			if _, err := io.Copy(out, orig); err != nil { | ||||||
| 				fmt.Printf("Error ovewriting original file: %v\n", err) | 				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 { | 		} else { | ||||||
| 			// we didn't actually change anything | 			// 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 | // 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 | 	// create a new temp file | ||||||
| 	tmpFile, err := ioutil.TempFile(os.TempDir(), "Goptimized-") | 	tmpFile, err := os.CreateTemp(os.TempDir(), "goptimize-") | ||||||
|  |  | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		fmt.Printf("Cannot create temporary file: %v\n", err) | 		fmt.Printf("Cannot create temporary file: %v\n", err) | ||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	defer os.Remove(tmpFile.Name()) | 	defer func() { _ = os.Remove(tmpFile.Name()) }() | ||||||
|  |  | ||||||
| 	source, err := os.Open(src) | 	source, err := os.Open(src) | ||||||
|  |  | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		fmt.Printf("Cannot open temporary file: %v\n", err) | 		fmt.Printf("Cannot open temporary file: %v\n", err) | ||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	defer source.Close() | 	defer func() { _ = source.Close() }() | ||||||
|  |  | ||||||
| 	if _, err := io.Copy(tmpFile, source); err != nil { | 	if _, err := io.Copy(tmpFile, source); err != nil { | ||||||
| 		fmt.Printf("Cannot copy source file: %v\n", err) | 		fmt.Printf("Cannot copy source file: %v\n", err) | ||||||
| @@ -265,8 +295,8 @@ func RunOptimiser(src string, outFileArg bool, args ...string) { | |||||||
| 	dstSize := dstStat.Size() | 	dstSize := dstStat.Size() | ||||||
|  |  | ||||||
| 	// ensure file pointers are closed before renaming | 	// ensure file pointers are closed before renaming | ||||||
| 	tmpFile.Close() | 	func() { _ = tmpFile.Close() }() | ||||||
| 	source.Close() | 	func() { _ = source.Close() }() | ||||||
|  |  | ||||||
| 	if dstSize < srcSize { | 	if dstSize < srcSize { | ||||||
| 		if err := os.Rename(tmpFilename, src); err != nil { | 		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 | // ByteCountSI returns a human readable size from int64 bytes | ||||||
| func ByteCountSI(b int64) string { | func byteCountSI(b int64) string { | ||||||
| 	const unit = 1000 | 	const unit = 1000 | ||||||
| 	if b < unit { | 	if b < unit { | ||||||
| 		return fmt.Sprintf("%dB", b) | 		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]) | 	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 | package main | ||||||
|  |  | ||||||
| import ( | import ( | ||||||
| 	"flag" |  | ||||||
| 	"fmt" | 	"fmt" | ||||||
| 	"os" | 	"os" | ||||||
| 	"os/exec" | 	"os/exec" | ||||||
| 	"regexp" | 	"regexp" | ||||||
|  | 	"runtime" | ||||||
| 	"strconv" | 	"strconv" | ||||||
|  | 	"sync" | ||||||
|  |  | ||||||
| 	"github.com/axllent/gitrel" | 	"github.com/axllent/ghru/v2" | ||||||
|  | 	"github.com/spf13/pflag" | ||||||
| ) | ) | ||||||
|  |  | ||||||
| var ( | var ( | ||||||
| @@ -22,15 +25,28 @@ var ( | |||||||
| 	optipng          string | 	optipng          string | ||||||
| 	pngquant         string | 	pngquant         string | ||||||
| 	gifsicle         string | 	gifsicle         string | ||||||
|  | 	copyExif         bool | ||||||
|  | 	threads          = 1 | ||||||
| 	version          = "dev" | 	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() { | func main() { | ||||||
|  | 	// set up new flag instance | ||||||
|  | 	flag := pflag.NewFlagSet(os.Args[0], pflag.ExitOnError) | ||||||
|  |  | ||||||
| 	// set the default help | 	// set the default help | ||||||
| 	flag.Usage = func() { | 	flag.Usage = func() { | ||||||
| 		fmt.Println("Goptimize - downscales and optimizes images") | 		fmt.Println("Goptimize - downscales and optimizes images") | ||||||
| 		fmt.Printf("\nUsage: %s [options] <images>\n", os.Args[0]) | 		fmt.Printf("\nUsage: %s [options] <images>\n", os.Args[0]) | ||||||
| 		fmt.Println("\nOptions:") | 		fmt.Println("\nOptions:") | ||||||
|  | 		flag.SortFlags = false | ||||||
| 		flag.PrintDefaults() | 		flag.PrintDefaults() | ||||||
| 		fmt.Println("\nExamples:") | 		fmt.Println("\nExamples:") | ||||||
| 		fmt.Printf("  %s image.png\n", os.Args[0]) | 		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.Printf("  %s -o out/ -q 90 -m 1600x1600 *.jpg\n", os.Args[0]) | ||||||
|  |  | ||||||
| 		fmt.Println("\nDetected optimizers:") | 		fmt.Println("\nDetected optimizers:") | ||||||
| 		if err := displayDelectedOptimizer("jpegtran ", jpegtran); err != nil { | 		if err := displayDetectedOptimizer("jpegtran ", jpegtran); err != nil { | ||||||
| 			displayDelectedOptimizer("jpegoptim", jpegoptim) | 			_ = displayDetectedOptimizer("jpegoptim", jpegoptim) | ||||||
| 		} | 		} | ||||||
| 		displayDelectedOptimizer("optipng  ", optipng) | 		_ = displayDetectedOptimizer("optipng  ", optipng) | ||||||
| 		displayDelectedOptimizer("pngquant ", pngquant) | 		_ = displayDetectedOptimizer("pngquant ", pngquant) | ||||||
| 		displayDelectedOptimizer("gifsicle ", gifsicle) | 		_ = displayDetectedOptimizer("gifsicle ", gifsicle) | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	var maxSizes string | 	var maxSizes string | ||||||
| 	var update, showversion bool | 	var multiThreaded, update, showVersion, showHelp bool | ||||||
|  |  | ||||||
| 	flag.IntVar(&quality, "q", 75, "quality - JPEG only") | 	flag.IntVarP(&quality, "quality", "q", 75, "quality, JPEG only") | ||||||
| 	flag.StringVar(&outputDir, "o", "", "output directory (default overwrites original)") | 	flag.StringVarP(&maxSizes, "max", "m", "", "downscale to a maximum width & height in pixels (<width>x<height>)") | ||||||
| 	flag.BoolVar(&preserveModTimes, "p", true, "preserve file modification times") | 	flag.StringVarP(&outputDir, "out", "o", "", "output directory (default overwrites original)") | ||||||
| 	flag.StringVar(&maxSizes, "m", "", "downscale to a maximum width & height in pixels (<width>x<height>)") | 	flag.BoolVarP(&preserveModTimes, "preserve", "p", true, "preserve file modification times") | ||||||
| 	flag.BoolVar(&update, "u", false, "update to latest release") | 	flag.BoolVarP(©Exif, "exif", "e", false, "copy exif data") | ||||||
| 	flag.BoolVar(&showversion, "v", false, "show version number") | 	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 | 	// 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(&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(&pngquant, "pngquant", "pngquant", "pngquant binary") | ||||||
|  | 	flag.StringVar(&optipng, "optipng", "optipng", "optipng binary") | ||||||
|  |  | ||||||
| 	// parse flags | 	flag.SortFlags = false | ||||||
| 	flag.Parse() |  | ||||||
|  | 	// 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 | 	// detect optimizer paths | ||||||
| 	gifsicle, _ = exec.LookPath(gifsicle) | 	gifsicle, _ = exec.LookPath(gifsicle) | ||||||
| @@ -73,23 +96,44 @@ func main() { | |||||||
| 	optipng, _ = exec.LookPath(optipng) | 	optipng, _ = exec.LookPath(optipng) | ||||||
| 	pngquant, _ = exec.LookPath(pngquant) | 	pngquant, _ = exec.LookPath(pngquant) | ||||||
|  |  | ||||||
| 	if showversion { | 	if showHelp { | ||||||
| 		fmt.Println(fmt.Sprintf("Version: %s", version)) | 		flag.Usage() | ||||||
| 		latest, _, _, err := gitrel.Latest("axllent/goptimize", "goptimize") | 		os.Exit(1) | ||||||
| 		if err == nil && latest != version { |  | ||||||
| 			fmt.Printf("Update available: %s\nRun `%s -u` to update.\n", latest, os.Args[0]) |  | ||||||
| 	} | 	} | ||||||
| 		return |  | ||||||
|  | 	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) | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		// 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 { | 	if update { | ||||||
| 		rel, err := gitrel.Update("axllent/goptimize", "goptimize", version) | 		// Update the app | ||||||
|  | 		rel, err := ghruConf.SelfUpdate() | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			fmt.Println(err) | 			fmt.Println(err.Error()) | ||||||
| 			os.Exit(1) | 			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 { | 	if len(flag.Args()) < 1 { | ||||||
| @@ -125,13 +169,38 @@ func main() { | |||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	for _, img := range args { | 	if multiThreaded { | ||||||
| 		Goptimize(img) | 		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 | // displayDetectedOptimizer prints whether the optimizer was found | ||||||
| func displayDelectedOptimizer(name, bin string) error { | func displayDetectedOptimizer(name, bin string) error { | ||||||
| 	exe, err := exec.LookPath(bin) | 	exe, err := exec.LookPath(bin) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return err | 		return err | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user