diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..903cb36 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,23 @@ +# top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending every file +[*] +end_of_line = lf +charset = utf-8 +indent_style = tab +indent_size = tab +tab_width = 4 +trim_trailing_whitespace = true + +# The property below is not yet universally supported +[*.md] +max_line_length = 108 +word_wrap = true +# Markdown sometimes uses two spaces at the end to +# mark soft line breaks +trim_trailing_whitespace = false + +[*.css] +indent_style = space +indent_size = 2 diff --git a/.gitignore b/.gitignore index 60f5f65..9a503b9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,57 @@ +~* +# Stupid macOS temporary files + +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +Icon? + +# Thumbnails +._* +nohup.out + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# Stuff from the Nova editor +.nova +node_modules +package.json +package-lock.json +.eslintrc.yml +.prettierrc.json +.env + +# logs +*.log +profile.out +coverage.html +coverage.txt +delay.txt + + +# executables +tinify-go + # Created by .ignore support plugin (hsz.mobi) ### Go template # Binaries for programs and plugins @@ -11,9 +65,7 @@ # Output of the go coverage tool, specifically when used with LiteIDE *.out +testdata/output/* -# Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736 -.glide/ -.idea/ -test_output/*.jpg -temp \ No newline at end of file +# Temporary debugger files +__* diff --git a/LICENSE b/LICENSE index 50e4cb0..bffd8f6 100644 --- a/LICENSE +++ b/LICENSE @@ -18,4 +18,4 @@ 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. +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index 0e7ba8a..53d45aa 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,14 @@ +![Tinify API client for Golang logo](testdata/assets/tinify-go-logo-pangopher-128x128.png) + # Tinify API client for Golang [:book: 国内的朋友看这里](http://www.jianshu.com/p/5c4161db4ac8) ------- +--- + +Golang client for the [Tinify API](https://tinypng.com/developers/reference), used for [TinyPNG](https://tinypng.com) and [TinyJPG](https://tinyjpg.com). Tinify compresses or resizes your images intelligently. Read more at [http://tinify.com](http://tinify.com). -Golang client for the Tinify API, used for [TinyPNG](https://tinypng.com) and [TinyJPG](https://tinyjpg.com). Tinify compresses or resize your images intelligently. Read more at [http://tinify.com](http://tinify.com). +The code on this repository is the work of volunteers who are neither affiliated with, nor endorsed by Tinify B.V., the makers of the Tinify API and of TinyPNG. ## Documentation @@ -12,29 +16,39 @@ Golang client for the Tinify API, used for [TinyPNG](https://tinypng.com) and [T ## Installation -Install the API client with `go get`. +Install the API client with `go install`. ```shell -go get -u github.com/gwpp/tinify-go +go install github.com/gwpp/tinify-go ``` +Note that this repository will install two different things: + +1. A port of the Tinify API to the Go programming language, which will be placed under its own directory `./tinify`, a stand-alone package/module named `Tinify`; +2. A client application, at the root of the repository, which will compile to an executable binary, using the `Tinify` package as an imported module. + +The client application serves both as a testing tool and as a stand-alone binary. It is very loosely based on other, similar client applications [written for other programming languages](https://github.com/tinify/). + +Remember, to use it, you need a valid Tinify API Key, passed wither with the `--key=XXXX` flag, or via the environment variable `TINIFY_API_KEY`. + ## Usage -- About key +- About the TinyPNG API key - Get your API key from https://tinypng.com/developers + Get your API key from https://tinypng.com/developers - Compress + ```golang func TestCompressFromFile(t *testing.T) { Tinify.SetKey(Key) - source, err := Tinify.FromFile("./test.jpg") + source, err := Tinify.FromFile("./testdata/input/test.jpg") if err != nil { t.Error(err) return } - err = source.ToFile("./test_output/CompressFromFile.jpg") + err = source.ToFile("./testdata/output/CompressFromFile.jpg") if err != nil { t.Error(err) return @@ -44,11 +58,12 @@ go get -u github.com/gwpp/tinify-go ``` - Resize + ```golang func TestResizeFromBuffer(t *testing.T) { Tinify.SetKey(Key) - buf, err := ioutil.ReadFile("./test.jpg") + buf, err := ioutil.ReadFile("./testdata/input/test.jpg") if err != nil { t.Error(err) return @@ -68,7 +83,7 @@ go get -u github.com/gwpp/tinify-go return } - err = source.ToFile("./test_output/ResizesFromBuffer.jpg") + err = source.ToFile("./testdata/output/ResizesFromBuffer.jpg") if err != nil { t.Error(err) return @@ -77,12 +92,11 @@ go get -u github.com/gwpp/tinify-go } ``` -- ***Notice:*** - - Tinify.ResizeMethod support `scale`, `fit` and `cover`. If used fit or cover, you must provide `both a width and a height`. But used scale, you must provide either a target width or a target height, `but not both`. +- **_Notice:_** + `Tinify.ResizeMethod()` supports `scale`, `fit` and `cover`. If you use either fit or cover, you must provide **both a width and a height**. But if you use scale, you must instead provide _either_ a target width _or_ a target height, **but not both**. -- More usage, please see [tinify_test.go](./tinify_test.go) +- For further usage, please read the comments in [tinify_test.go](./tinify_test.go) ## Running tests @@ -91,6 +105,20 @@ cd $GOPATH/src/github.com/gwpp/tinify-go go test ``` +## Command-line utility + +To build it: + +```shell +cd $GOPATH/src/github.com/gwpp/tinify-go +go build -ldflags "-X main.TheBuilder=$USER" +# or, if you prefer, `go install` +``` + +and then invoke `./tinify-go --help` to get some basic instructions for the CLI. + ## License -This software is licensed under the MIT License. [View the license](LICENSE). +This software is licensed under the [MIT License](LICENSE). + +The logo (a cross-breed between the gopher mascot and the TinyPNG panda!) was provided courtesy of Microsoft's image generative AI, which is currently based on OpenAI's DALL-E technology. diff --git a/aux_test.go b/aux_test.go new file mode 100644 index 0000000..7dce253 --- /dev/null +++ b/aux_test.go @@ -0,0 +1,58 @@ +// Test suite for auxiiary functions. +// The main testing, done by gwpp, is under tinify_test.go +package main + +import ( + "testing" +) + +var tests = []struct { + hex string + check bool +}{ + {"#000000", true}, + {"00FF", true}, + {"0#0", false}, + {"", false}, + {"#", false}, + {"#F", false}, + {"B", false}, + {"abcdef01", true}, + {"#abcdef01", true}, + {"#abcdef0", false}, + {"#abcdefg0", false}, + {"#0f0", false}, + {"0x0F", false}, + {"10000", false}, +} + +// Given a series of what we consider to be valid colours in hexadecimal, test if +// we got the expected results. +func TestIsValidHex(t *testing.T) { + for _, tc := range tests { + if isValidHex(tc.hex) != tc.check { + t.Fatalf("checked %q if it was valid hex or not and expected %t", tc.hex, tc.check) + } + } +} + +func BenchmarkIsValidHex(b *testing.B) { + for b.Loop() { + for _, tc := range tests { + if isValidHex(tc.hex) != tc.check { + b.Fatalf("checked %q if it was valid hex or not and expected %t", tc.hex, tc.check) + } + } + } +} + +/* +func BenchmarkIsValidHexChatGPT(b *testing.B) { + for b.Loop() { + for _, tc := range tests { + if isValidHexChatGPT(tc.hex) != tc.check { + b.Fatalf("checked %q if it was valid hex or not and expected %t", tc.hex, tc.check) + } + } + } +} */ diff --git a/buildinfo.go b/buildinfo.go new file mode 100644 index 0000000..84aed22 --- /dev/null +++ b/buildinfo.go @@ -0,0 +1,133 @@ +// Auxiliary functions to return information from the built executable, +// such as version, Git commit, architcture, etc. +package main + +import ( + "fmt" + "os" + "runtime" + "runtime/debug" + "time" + + "github.com/rs/zerolog" +) + +// versionInfoType holds the relevant information for this build. +// It is meant to be used as a cache. +type versionInfoType struct { + version string // Runtime version. + commit string // Commit revision number. + dateString string // Commit revision time (as a RFC3339 string). + date time.Time // Same as before, converted to a time.Time, because that's what the cli package uses. + builtBy string // User who built this (see note). + goOS string // Operating system for this build (from runtime). + goARCH string // Architecture, i.e., CPU type (from runtime). + goVersion string // Go version used to compile this build (from runtime). + init bool // Have we already initialised the cache object? +} + +// NOTE: I don't know where the "builtBy" information comes from, so, right now, it gets injected +// during build time, e.g. `go build -ldflags "-X main.TheBuilder=gwyneth"` (gwyneth 20231103) +// NOTE: LoggingLevel is set in main. + +var ( + versionInfo *versionInfoType // cached values for this build. + TheBuilder string // to be overwritten via the linker command `go build -ldflags "-X main.TheBuilder=gwyneth"`. + TheVersion string // to be overwritten with -X main.TheVersion=X.Y.Z, as above. +) + +// Initialises a versionInfo variable. +func initVersionInfo() (vI *versionInfoType, err error) { + vI = new(versionInfoType) + if vI.init { + // already initialised, no need to do anything else! + return vI, nil + } + // get the following entries from the runtime: + vI.goOS = runtime.GOOS + vI.goARCH = runtime.GOARCH + vI.goVersion = runtime.Version() + + // attempt to get some build info as well: + buildInfo, ok := debug.ReadBuildInfo() + if !ok { + return nil, fmt.Errorf("no valid build information found") + } + // use our supplied version instead of the long, useless, default Go version string. + if TheVersion == "" { + vI.version = buildInfo.Main.Version + } else { + vI.version = TheVersion + } + + // Now dig through settings and extract what we can... + + var vcs, rev string // Name of the version control system name (very likely Git) and the revision. + for _, setting := range buildInfo.Settings { + switch setting.Key { + case "vcs": + vcs = setting.Value + case "vcs.revision": + rev = setting.Value + case "vcs.time": + vI.dateString = setting.Value + } + } + vI.commit = "unknown" + if vcs != "" { + vI.commit = vcs + } + if rev != "" { + vI.commit += " [" + rev + "]" + } + // attempt to parse the date, which comes as a string in RFC3339 format, into a date.Time: + var parseErr error + if vI.date, parseErr = time.Parse(vI.dateString, time.RFC3339); parseErr != nil { + // Note: we can safely ignore the parsing error: either the conversion works, or it doesn't, and we + // cannot do anything about it... (gwyneth 20231103) + // However, the AI revision bots dislike this, so we'll assign the current date instead. + vI.date = time.Now() + + // We'll log this just in case. + switch setting.LoggingLevel { + case zerolog.LevelTraceValue, zerolog.LevelDebugValue, zerolog.LevelErrorValue, zerolog.LevelInfoValue: + fmt.Fprintf(os.Stderr, "date parse error: %v — falling back to today", parseErr) + } + } + + // see comment above + vI.builtBy = TheBuilder + // Mark initialisation as complete before returning. + vI.init = true + return vI, nil +} + +// Returns a pretty-printed version of versionInfo, respecting the String() syntax. +func (vI *versionInfoType) String() string { + // check if we have a valid builder's name; if yes, add it to the return string. + // (gwyneth 20251007) + var builtBy string + if len(vI.builtBy) > 0 { + builtBy = " by " + vI.builtBy + } + + return fmt.Sprintf( + "\t%s\n\t(rev %s)\n\t[%s %s %s]\n\tBuilt on %s%s", + vI.version, + vI.commit, + vI.goOS, + vI.goARCH, + vI.goVersion, + vI.dateString, // Date as string in RFC3339 notation. + builtBy, + ) +} + +// Initialises a global, pre-defined versionInfo variable (we might just need one). +// Panics if allocation failed! +func init() { + var err error + if versionInfo, err = initVersionInfo(); err != nil { + panic(err) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ed3e1da --- /dev/null +++ b/go.mod @@ -0,0 +1,19 @@ +module github.com/gwpp/tinify-go + +go 1.24.6 + +require ( + github.com/GwynethLlewelyn/justify v0.2.1 + github.com/joho/godotenv v1.5.1 + github.com/rs/zerolog v1.34.0 + github.com/urfave/cli/v3 v3.4.1 + golang.org/x/term v0.36.0 +) + +require ( + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + golang.org/x/sys v0.37.0 // indirect +) + +replace github.com/gwpp/tinify-go v0.0.0-20170613055357-77b9df15f343 => github.com/GwynethLlewelyn/tinify-go v0.1.1-0.20231112021032-de06fee9c2ac diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..289c7f6 --- /dev/null +++ b/go.sum @@ -0,0 +1,36 @@ +github.com/GwynethLlewelyn/justify v0.0.0-20251012112935-828d593f1c20 h1:G+aOBigkciCx1B7meAz81AV4lODL3Hr3/EnYac8GzuI= +github.com/GwynethLlewelyn/justify v0.0.0-20251012112935-828d593f1c20/go.mod h1:+J67K0OEEfiAYoBpphiy3Pzlr+hsTZPV7LsSQtbLXA4= +github.com/GwynethLlewelyn/justify v0.2.1 h1:LPVoHHuoxQAbOyluWJU0YoO9Nd3wyXsu29jtMy7M2dI= +github.com/GwynethLlewelyn/justify v0.2.1/go.mod h1:+J67K0OEEfiAYoBpphiy3Pzlr+hsTZPV7LsSQtbLXA4= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/urfave/cli/v3 v3.4.1 h1:1M9UOCy5bLmGnuu1yn3t3CB4rG79Rtoxuv1sPhnm6qM= +github.com/urfave/cli/v3 v3.4.1/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= +golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/test_output/README.md b/testdata/README.md similarity index 100% rename from test_output/README.md rename to testdata/README.md diff --git a/testdata/assets/tinify-go-logo-pangopher-128x128.png b/testdata/assets/tinify-go-logo-pangopher-128x128.png new file mode 100644 index 0000000..24e3638 Binary files /dev/null and b/testdata/assets/tinify-go-logo-pangopher-128x128.png differ diff --git a/testdata/assets/tinify-go-logo-pangopher.png b/testdata/assets/tinify-go-logo-pangopher.png new file mode 100644 index 0000000..0f8d3a9 Binary files /dev/null and b/testdata/assets/tinify-go-logo-pangopher.png differ diff --git a/test.jpg b/testdata/input/test.jpg similarity index 100% rename from test.jpg rename to testdata/input/test.jpg diff --git a/tinify-go.go b/tinify-go.go new file mode 100644 index 0000000..9e7572d --- /dev/null +++ b/tinify-go.go @@ -0,0 +1,651 @@ +// Original Go Tinify library: Copyright (c) 2017 gwpp +// Distributed under a MIT licence. +// Additional coding and CLI example (c) 2025 by Gwyneth Llewelyn, +// also under a MIT licence. +package main + +import ( + "context" + "fmt" + "io" + "net/mail" + "os" + "path/filepath" + "slices" + "strings" + "time" + + "github.com/GwynethLlewelyn/justify" + Tinify "github.com/gwpp/tinify-go/tinify" + _ "github.com/joho/godotenv/autoload" + "github.com/rs/zerolog" + "github.com/urfave/cli/v3" + "golang.org/x/term" +) + +// No harm is done having just one context, which is simoly the background. +var ctx = context.Background() + +// Type to hold the global variables for all possible calls. +type Setting struct { + LoggingLevel string `json:"log_level"` // Debug/verbosity level, "error" by default + ImageName string `json:"image_name"` // Filename or URL. + OutputFileName string `json:"output_file_name"` // If set, it's the output filename; if not, well... + FileType string `json:"file_type"` // Any set of webp, png, jpg, avif. + Key string `json:"key"` // TinyPNG API key; can be on environment or read from .env. + Logger zerolog.Logger `json:"-"` // The main setting.Logger. + Method string `json:"method"` // Resizing method (scale, fit, cover, thumb). + Width int64 `json:"width"` // Image width (for resize operations). + Height int64 `json:"height"` // Image height ( " " " " ). + Transform string `json:"transform"` // Transform the background to one of 'black', 'white', or hex value. + TerminalWidth int `json:"terminal_width"` // If we're on a TTY, stores the width; 80 is default. + CompressionCount int64 `json:"compression_count"` // A measure of how many crdits are still left for further compression. +} + +// Global settings for this CLI app. +var setting Setting + +// Tinify API supported file types. +// Add more when TinyPNG supports additional types. +var types = []string{ + "png", + "jpeg", + "webp", + "avif", +} + +// Available image resizing methods. +// Add more when TinyPNG supports additional types. +var methods = []string{ + Tinify.ResizeMethodScale, + Tinify.ResizeMethodFit, + Tinify.ResizeMethodCover, + Tinify.ResizeMethodThumb, +} + +// Main starts here. +func main() { + var err error // declared here due to scoping issues. + + // Set up the version/runtime/debug-related variables, and cache them. + // `versionInfo` is a global which has very likely been already initialised. + if versionInfo, err = initVersionInfo(); err != nil { + panic(fmt.Sprintf("Failed to initialize version info: %v\n", err)) + } + + // Check if we have the API key on environment. + // Note that we are using godotenv/autoload to automatically retrieve .env + // and merge with the existing environment. + setting.Key = os.Getenv("TINIFY_API_KEY") + + startLevel := zerolog.ErrorLevel + // Debug override (for testing purposes): check environmnt, change debug level if present. + if debugOverride := os.Getenv("TINIFY_API_DEBUG"); len(debugOverride) > 0 { + if startLevel, err = zerolog.ParseLevel(debugOverride); err != nil { + // when zerolog.ParseLevel() fails, it returns zerolog.NoLevel; we'll go back to default (zerolog.ErrorLevel). + startLevel = zerolog.ErrorLevel + } + } + + // testing zerolog: + setting.Logger = zerolog.New(zerolog.ConsoleWriter{ + Out: os.Stderr, + TimeFormat: time.DateTime, + PartsOrder: []string{ + zerolog.LevelFieldName, + zerolog.MessageFieldName, + zerolog.CallerFieldName, + }, + FormatCaller: func(i any) string { + return "(" + filepath.Base(fmt.Sprintf("%s", i)) + ")" + }, + }). + Level(startLevel). + With(). + Caller(). + Logger() + + // while testing, temporarily override everything and put the loggr in trace mode. + // tinifyLoggingLevel := zerolog.TraceLevel + // setting.LoggingLevel = zerolog.LevelTraceValue + + // Note that the zerolog setting.Logger is *always* returned; if it cannot write to the log + // for some reason, that error will be handled by the zerolog passage, thus + // the simple `Debug()` call here: if this _fails_, we've not done anything yet with + // the images, and can safely abort. + setting.Logger.Debug().Msgf("setting.Logger started at logging level %q; tinify pkg version %s", + setting.Logger.GetLevel(), + Tinify.VERSION) + + // check for terminal width if we're on a TTY + setting.TerminalWidth = 80 + if term.IsTerminal(int(os.Stdin.Fd())) { + setting.Logger.Debug().Msgf("TTY detected on stdin") + width, _, err := term.GetSize(int(os.Stdin.Fd())) + if err == nil { + setting.TerminalWidth = width + } else { + setting.Logger.Debug().Msgf("could not get the size of the TTY: %s", err) + } + } + + setting.Logger.Debug().Msgf("Terminal width set to %d", setting.TerminalWidth) + + // Contains information about the compiled code in a format that urfave/cli likes. + metadata := map[string]any{ + "Version": versionInfo.version, + "Commit": versionInfo.commit, + "Date": versionInfo.dateString, + "Built by": versionInfo.builtBy, + "OS": versionInfo.goOS, + "Architecture": versionInfo.goARCH, + "Go version": versionInfo.goVersion, + } + + // start CLI app + cmd := &cli.Command{ + Name: "tinify-go", + Usage: justify.Justify("Calls the Tinify API from TinyPNG "+func() string { + if len(setting.Key) < 5 { + return "(environment variable TINIFY_API_KEY not set or invalid key)" + } + return "(with key [..." + setting.Key[len(setting.Key)-4:] + "])" + }(), setting.TerminalWidth), + UsageText: justify.Justify(os.Args[0]+" [OPTION] [FLAGS] [INPUT FILE] [OUTPUT FILE]\nWith no INPUT FILE, or when INPUT FILE is -, read from standard input.", setting.TerminalWidth), + Version: fmt.Sprint(versionInfo), + DefaultCommand: "compress", + EnableShellCompletion: true, + Suggest: true, // see https://cli.urfave.org/v3/examples/help/suggestions/ + Metadata: metadata, + Authors: []any{ + &mail.Address{Name: "gwpp", Address: "ganwenpeng1993@163.com"}, + &mail.Address{Name: "Gwyneth Llewelyn", Address: "gwyneth.llewelyn@gwynethllewelyn.net"}, + }, + Copyright: justify.Justify(fmt.Sprintf("© 2017-%d by Ganwen Peng. All rights reserved. Freely distributed under an MIT license.", time.Now().Year()), setting.TerminalWidth), + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "input", + Aliases: []string{"i"}, + Usage: "input filename (empty=STDIN)", + Destination: &setting.ImageName, + }, + &cli.StringFlag{ + Name: "output", + Aliases: []string{"o"}, + Usage: "output filename (empty=STDOUT)", + Destination: &setting.OutputFileName, + }, + &cli.StringFlag{ + Name: "debug", + Aliases: []string{"d"}, + Usage: "debug level; \"error\" means no logging", + Value: zerolog.LevelErrorValue, + Destination: &setting.LoggingLevel, + Action: func(ctx context.Context, c *cli.Command, s string) error { + // Check if the debug level is valid: it must be one of the zerolog valid types. + // NOTE: This will be set later on anyway... + // NOTE: setting.LoggingLevel will *always* be set to the default value, no matter what. + setting.Logger.Debug().Msgf("cli.StringFlag(): setting logging level to... %q", setting.LoggingLevel) + return setLogLevel() + }, + }, + }, + Commands: []*cli.Command{ + { + Name: "version", + Aliases: []string{"v"}, + Usage: "show version and compilation data", + Action: func(ctx context.Context, c *cli.Command) error { + fmt.Println(versionInfo) + return nil + }, + }, + { + Name: "compress", + Aliases: []string{"comp"}, + Usage: "compresses and optimises an image", + UsageText: justify.Justify("You can upload any image to the Tinify API to compress it. We will automatically detect the type of image ("+strings.Join(types, ", ")+") and optimise with the TinyPNG or TinyJPG engine accordingly.\nCompression will start as soon as you upload a file or provide the URL to the image.", setting.TerminalWidth), + Action: compress, + }, + { + Name: "resize", + Aliases: []string{"r"}, + Usage: "resizes the image to a new size, using one of the possible methods", + UsageText: justify.Justify("Use the API to create resized versions of your uploaded images.\nBy letting the API handle resizing you avoid having to write such code yourself and you will only have to upload your image once. The resized images will be optimally compressed with a nice and crisp appearance.\nYou can also take advantage of intelligent cropping to create thumbnails that focus on the most visually important areas of your image.\nResizing counts as one additional compression. For example, if you upload a single image and retrieve the optimized version plus 2 resized versions this will count as 3 compressions in total.\nAvailable compression methods are: "+strings.Join(methods, ", "), setting.TerminalWidth), + Action: resize, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "method", + Aliases: []string{"m"}, + Value: Tinify.ResizeMethodScale, + Usage: "resizing method [" + strings.Join(methods, ", ") + "]", + Destination: &setting.Method, + Action: func(ctx context.Context, c *cli.Command, s string) error { + // Check if the resizing method is a valid one. + // First check if it's empty: + if len(setting.Method) == 0 { + setting.Method = Tinify.ResizeMethodScale // scale is default + } else if !slices.Contains(methods, setting.Method) { + // Checked if it's one of the valid methods; if not, abort. + setting.Logger.Fatal().Msgf("invalid resize method: %q", setting.Method) + return fmt.Errorf("invalid resize method: %q", setting.Method) + } + return nil + }, + }, + &cli.Int64Flag{ + Name: "width", + Aliases: []string{"w"}, + Value: 0, + Usage: "destination image width", + Destination: &setting.Width, + }, + &cli.Int64Flag{ + Name: "height", + Aliases: []string{"g"}, + Value: 0, + Usage: "destination image height", + Destination: &setting.Height, + }, + }, + }, + { + Name: "convert", + Aliases: []string{"conv"}, + Usage: "converts from one file type to another (" + strings.Join(types, ", ") + " supported)", + UsageText: justify.Justify("You can use the API to convert your images to your desired image type.\nTinify currently supports converting between: "+strings.Join(types, ", ")+".\nWhen you provide more than on image type in your convert request, the smallest version will be returned to you.\nImage converting will count as one additional compression.", setting.TerminalWidth), + Action: convert, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "type", + Aliases: []string{"t"}, + Usage: "file type [" + strings.Join(types, ", ") + "]", + Value: "webp", + DefaultText: "webp", + Destination: &setting.FileType, + Action: func(ctx context.Context, c *cli.Command, s string) error { + // Check if the type(s) are all valid: + if setting.FileType != "" { + typesFound := strings.Split(setting.FileType, ",") + if typesFound == nil { + return fmt.Errorf("convert: no valid file types found") + } + // A very inefficient way of checking if all file types are valid O(n). + // TODO(gwyneth): See if there is already a library function for this, + // or use a different, linear approach. + for _, aFoundType := range typesFound { + if !slices.Contains(types, aFoundType) { + return fmt.Errorf("convert: invalid file format: %q", aFoundType) + } + } + // if we're here, all file types are valid + setting.Logger.Debug().Msg("convert: all file type parameters are valid") + } else { + setting.Logger.Debug().Msg("convert: no file type parameters found") + } + return nil + }, + }, + }, + }, + { + Name: "transform", + Aliases: []string{"tr"}, + Usage: "processes image further (currently only replaces the background with a solid colour)", + UsageText: justify.Justify("If you wish to convert an image with a transparent background to one with a solid background, specify a background property in the transform object.\nIf this property is provided, the background of a transparent image will be filled (only \"white\", \"black\", or a hex value are allowed).", setting.TerminalWidth), + Action: transform, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "background", + Aliases: []string{"bg"}, + Value: "", + Usage: "only \"white\", \"black\", or a hex value are allowed", + Destination: &setting.Transform, + Action: func(ctx context.Context, c *cli.Command, s string) error { + // Check if value passed is correct. + setting.Transform = strings.ToLower(setting.Transform) + if setting.Transform == "white" || setting.Transform == "black" { + return nil + } + // Just check if the rmaining string is a valid hex string. + // (gwyneth 20250713) + if !isValidHex(setting.Transform) { + return fmt.Errorf("background colour: invalid hex value") + } + return nil + }, + }, + }, + }, + }, + CommandNotFound: func(ctx context.Context, cmd *cli.Command, command string) { + setting.Logger.Fatal().Msgf("Command %q not found.\nUsage: %s", command, cmd.UsageText) + }, + OnUsageError: func(ctx context.Context, cmd *cli.Command, err error, isSubcommand bool) error { + if isSubcommand { + return err + } + + setting.Logger.Error().Msgf("Wrong usage: %#v", err) + return nil + }, + Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) { + // Setup phase + + setting.Logger.Debug().Msgf("Before action inside loop: before calling setLogLevel(), logging level was: %q(%d)", + setting.LoggingLevel, + setting.Logger.GetLevel()) + + // force new debugging level, if it was set (gwyneth 20251007) + // NOTE: we can safely ignore the error here. + setLogLevel() + + setting.Logger.Debug().Msgf("Before action inside loop: after calling setLogLevel(), log level is now set to: %q(%d)", + setting.LoggingLevel, + setting.Logger.GetLevel()) + + // Check if key is somewhat valid, i.e. has a decent amount of chars: + if len(setting.Key) < 5 { + return ctx, fmt.Errorf("invalid Tinify API key %q; too short — please check your key and try again", setting.Key) + } + + // Now safely set the API key + Tinify.SetKey(setting.Key) + setting.Logger.Debug().Msgf("Before action inside loop: a Tinify API key was found: [...%s]", setting.Key[len(setting.Key)-4:]) + + return ctx, nil + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + // Everything not defined above happens here! + + setting.Logger.Debug().Msg("Reached empty Action block") + return nil + }, + } + + // cli.CommandHelpTemplate = commandHelpTemplate + + setting.Logger.Debug().Msgf("Log level before outside loop: %q(%d)", + setting.LoggingLevel, + setting.Logger.GetLevel(), + ) + + if err := cmd.Run(ctx, os.Args); err != nil { + // setting.Logger.Fatal().Err(err) + setting.Logger.Fatal().Msg(err.Error()) + } +} // main + +// openStream attempts to open a file, stdin, or a URL, and passes the image along for +// processing by the API. +func openStream(ctx context.Context) (context.Context, *Tinify.Source, error) { + // Input file may be either an image filename or an URL; TinyPNG will handle both. + // Since `://` is hardly a valid filename, but a requirement for being an URL, + // handle URL later. + // Note that if setting.ImageName is unset, stdin is assumed, even if it might not yet work. + + var ( + err error // declared here due to scope issues. + f = os.Stdin // file handler; STDIN by default. + rawImage []byte // raw image file, when loaded from disk. + source *Tinify.Source + ) + + setting.Logger.Debug().Msgf("openStream: opening input file for reading: %q", setting.ImageName) + if setting.ImageName == "" || !strings.Contains(setting.ImageName, "://") { + if setting.ImageName == "" { + // empty filename; use stdin + f = os.Stdin + + // are we on a TTY, or getting content from a pipe? + if term.IsTerminal(int(f.Fd())) { + return ctx, nil, fmt.Errorf("openStream: cannot read interactively from a TTY; use --input or pipe a file to STDIN") + } + + // Logging to console, so let the user knows that as well + setting.Logger.Info().Msg("openStream: empty filename; reading from console/stdin instead") + } else { + // check to see if we can open this file: + f, err = os.Open(setting.ImageName) + if err != nil { + return ctx, nil, err + } + setting.Logger.Debug().Msgf("openStream: %q sucessfully opened", setting.ImageName) + } + // Get the image file from disk/stdin. + rawImage, err = io.ReadAll(f) + if err != nil { + return ctx, nil, err + } + + setting.Logger.Debug().Msgf("openStream: arg: %q (empty means stdin), size %d", setting.ImageName, len(rawImage)) + + // Now call the TinyPNG API + source, err = Tinify.FromBuffer(rawImage) + if err != nil { + return ctx, nil, err + } + } else { + // we're assuming that we've got a valid URL, which might *not* be the case! + // TODO(Tasker): extra validation + source, err = Tinify.FromUrl(setting.ImageName) + if err != nil { + return ctx, nil, err + } + } + return ctx, source, nil +} + +// All-purpose API call. Whatever is done, it happens on the globals. +func callAPI(_ context.Context, cmd *cli.Command, source *Tinify.Source) error { + var ( + err error // declared here due to scope issues. + rawImage []byte // raw image file, when loaded from disk. + ) + + if len(cmd.Name) == 0 { + return fmt.Errorf("no command") + } + setting.Logger.Debug().Msgf("inside callAPI(), invoked by %q", cmd.Name) + + // If we have no explicit output filename, write directly to stdout. + if len(setting.OutputFileName) == 0 { + setting.Logger.Debug().Msg("callAPI: no output filename; writing to stdout instead") + // Warning: `source` is a global variable in this context!. + rawImage, setting.CompressionCount, err = source.ToBufferC() + if err != nil { + setting.Logger.Error().Err(err) + return err + } + // rawImage contains the raw image data; we push it out to STDOUT. + n, err := os.Stdout.Write(rawImage) + if err != nil { + setting.Logger.Error().Err(err) + return err + } + + setting.Logger.Debug().Msgf("callAPI: wrote %d byte(s) to stdout; compression count: %d", n, setting.CompressionCount) + return nil + } + + setting.Logger.Debug().Msgf("callAPI: opening file %q for outputting image", setting.OutputFileName) + + // write to file, we have a special function for that already defined: + setting.CompressionCount, err = source.ToFileC(setting.OutputFileName) + if err != nil { + setting.Logger.Error().Err(err) + return err + } + + setting.Logger.Debug().Msgf("callAPI: succesfully wrote to %q, compression count: %d", setting.OutputFileName, setting.CompressionCount) + return nil +} + +// Tries to get a list of types to covert to, and calls the API. +func convert(ctx context.Context, cmd *cli.Command) error { + var ( + err error // declared here due to scope issues. + source *Tinify.Source + ) + + setting.Logger.Debug().Msg("convert called") + + if ctx, source, err = openStream(ctx); err != nil { + setting.Logger.Error().Msgf("convert: invalid filenames, error was %v", err) + return err + } + + // user can request conversion to multiple file types, comma-separated; we need to split + // these since our Convert logic presumes maps of strings, to properly JSONificta them, + if err := source.Convert(strings.Split(strings.ToLower(setting.FileType), ",")); err != nil { + return err + } + // again, note that `source` is a global. + return callAPI(ctx, cmd, source) +} + +// Resizes image, given a width and a height. +func resize(ctx context.Context, cmd *cli.Command) error { + var ( + err error // declared here due to scope issues. + source *Tinify.Source + ) + setting.Logger.Debug().Msgf("resize called; debug is %q, method is %q, width is %d px, height is %d px", + setting.LoggingLevel, setting.Method, setting.Width, setting.Height) + + // width and height are globals. + if setting.Width == 0 && setting.Height == 0 { + setting.Logger.Error().Msg("resize: width and height cannot be simultaneously zero") + return fmt.Errorf("resize: width and height cannot be simultaneously zero") + } + + setting.Logger.Debug().Msg("resize: now calling openStream()") + + if ctx, source, err = openStream(ctx); err != nil { + setting.Logger.Error().Msgf("resize: invalid filenames, error was %v", err) + return err + } + + setting.Logger.Debug().Msg("resize: now calling source.Resize()") + + // method is a global too. + err = source.Resize(&Tinify.ResizeOption{ + Method: Tinify.ResizeMethod(setting.Method), + Width: setting.Width, // replace by real value! + Height: setting.Height, + }) + + if err != nil { + setting.Logger.Error().Err(err) + return err + } + + return callAPI(ctx, cmd, source) +} + +// Compress is the default. +func compress(ctx context.Context, cmd *cli.Command) error { + var ( + err error // declared here due to scope issues. + source *Tinify.Source + ) + + setting.Logger.Debug().Msg("compress called") + + if ctx, source, err = openStream(ctx); err != nil { + setting.Logger.Error().Msgf("compress: invalid filenames, error was: %v", err) + return err + } + + return callAPI(ctx, cmd, source) +} + +// Transform allows o remove the background (that's the only option in the Tinify API so far). +func transform(ctx context.Context, cmd *cli.Command) error { + var ( + err error // declared here due to scope issues. + source *Tinify.Source + ) + + setting.Logger.Debug().Msg("transform called") + if len(setting.Transform) == 0 { + return fmt.Errorf("transform: empty transformation type passed") + } + + if ctx, source, err = openStream(ctx); err != nil { + setting.Logger.Error().Msgf("transform: invalid filenames, error was %v", err) + return err + } + + if err = source.Transform(&Tinify.TransformOptions{ + Background: setting.Transform, + }); err != nil { + return err + } + return callAPI(ctx, cmd, source) +} + +// Aux functions + +// setLogLevel is just a macro-style thing to force the logging level to be set. +func setLogLevel() error { + setting.Logger.Debug().Msgf("setLogLevel(): log level to be set: %q", setting.LoggingLevel) + if len(setting.LoggingLevel) > 0 { + if tinifyLoggingLevel, err := zerolog.ParseLevel(setting.LoggingLevel); err == nil { + // Ok, valid error level selected, set it: + setting.Logger = setting.Logger.Level(tinifyLoggingLevel) + setting.LoggingLevel = tinifyLoggingLevel.String() + setting.Logger.Debug().Msgf("setLogLevel(): successfully set logging level to %q (%d)", + setting.LoggingLevel, + setting.Logger.GetLevel(), + ) + return nil + } else { + setting.Logger.Debug().Msgf("setLogLevel(): error parsing logging level %q: %s", + setting.LoggingLevel, err) + } + } + // Unknown logging level, or empty logging level, so fall back to "error" + setting.Logger.Debug().Msgf("setLogLevel(): empty or unknown logging level %q, falling back to \"error\"", + setting.LoggingLevel) + setting.Logger = setting.Logger.Level(zerolog.ErrorLevel) + setting.LoggingLevel = zerolog.ErrorLevel.String() + + return fmt.Errorf("unknown logging type %q, setting to \"error\" (%d) by default", + setting.LoggingLevel, setting.Logger.GetLevel()) +} + +// check if this is a valid Hex value for a colour or not. +// allow CSS RGB types of colours, with or without # +// 3 digits, 6 digits, or 8 digits (for transpareny) accepted. +// See https://stackoverflow.com/a/79589454/1035977 +// (gwyneth 20250713) +func isValidHex(s string) bool { + n := len(s) + // empty string or string with just a '#'? + if n == 0 { + return false + } + + start := 0 + if s[0] == '#' { + n-- + start = 1 + } + + // check for valid ranges + if n != 4 && n != 6 && n != 8 { + return false + } + + // must be "#xxx" or "#xxxxxx" + // check each hex digit + for i := start; i < n; i++ { + b := s[i] | 0x20 // fold A–F into a–f, digits unaffected + if b-'0' < 10 || b-'a' < 6 { // '0'–'9' or 'a'–'f' ? + continue + } + return false + } + return true +} diff --git a/tinify/LICENSE b/tinify/LICENSE new file mode 100644 index 0000000..bffd8f6 --- /dev/null +++ b/tinify/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 gwpp + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/tinify/README.md b/tinify/README.md new file mode 100644 index 0000000..7c2a27c --- /dev/null +++ b/tinify/README.md @@ -0,0 +1,5 @@ +# Tinify API + +This package implements a simple, native Go interface to TinyPNG's Tinify API. + +This software is licensed under the [MIT License](LICENSE). diff --git a/tinify/client.go b/tinify/client.go index 5e3c930..b4e2296 100644 --- a/tinify/client.go +++ b/tinify/client.go @@ -3,53 +3,138 @@ package Tinify import ( "bytes" "encoding/json" - "io/ioutil" + "fmt" + "io" + "log" "net/http" + "net/url" "strings" + "time" ) +// Standard Tinify API endpoint. const API_ENDPOINT = "https://api.tinify.com" +// This allows any consumer of this package to be aware of any proxies used. +var tinifyProxyTransport *http.Transport + +// Type for the TinyPNG API client. type Client struct { - options map[string]interface{} - key string + options map[string]any // List of options to call the Tinify API. + key string // TinyPNG API key. + proxy string // Specific HTTP(S) proxy server for this client. } +// Creates a new TinyPNG API client by allocating some memory for it. func NewClient(key string) (c *Client, err error) { c = new(Client) c.key = key return } -// method: http.MethodPost、http.MethodGet -func (c *Client) Request(method string, url string, body interface{}) (response *http.Response, err error) { - if strings.HasPrefix(url, "https") == false { - url = API_ENDPOINT + url +// HTTP(S) request which can either send raw bytes (for an image) and/or a JSON-formatted request. +func (c *Client) Request(method string, urlRequest string, body any) (response *http.Response, err error) { + // NOTE: this should go through a bit more validation. We are deferring such + // validation to the Go library functions that do the actual request. + if !strings.HasPrefix(urlRequest, "https") { // shouldn't we check for uppercase as well? (gwyneth 20231111) + urlRequest = API_ENDPOINT + urlRequest + } + // Deal with HTTP(S) proxy for this request. + tinifyProxyTransport.Proxy = c.reconfigureProxyTransport("") // the parameter is possibly irrelevant + + httpClient := http.Client{ + Transport: tinifyProxyTransport, } - req, err := http.NewRequest(method, url, nil) + + req, err := http.NewRequest(method, urlRequest, nil) if err != nil { - return + return nil, fmt.Errorf("request to %q using method %q failed; error was: %s", urlRequest, method, err) } - switch body.(type) { + // Clunky! But it works :-) + // If the body is a raw binary image, send it raw via an ioReader. + // Otherwise, the body will need to be sent as JSON (per API). So first we construct a JSONified + // representation of the struct we've got; and *then* send the result via an ioReader. + switch b := body.(type) { case []byte: - if len(body.([]byte)) > 0 { - req.Body = ioutil.NopCloser(bytes.NewReader(body.([]byte))) + if len(b /*body.([]byte)*/) > 0 { + req.Body = io.NopCloser(bytes.NewReader(b /*body.([]byte)*/)) } - case map[string]interface{}: - if len(body.(map[string]interface{})) > 0 { - body2, err2 := json.Marshal(body) + case map[string]any: + if len(b /*body.(map[string]interface{})*/) > 0 { + bodyJSON, err2 := json.Marshal(body) if err2 != nil { err = err2 return } - req.Body = ioutil.NopCloser(bytes.NewReader(body2)) + req.Body = io.NopCloser(bytes.NewReader(bodyJSON)) } req.Header["Content-Type"] = []string{"application/json"} + default: + return nil, fmt.Errorf("invalid request body; must be either an image or a JSON object") } req.SetBasicAuth("api", c.key) - response, err = http.DefaultClient.Do(req) + response, err = httpClient.Do(req) return } + +// Attempts to reconfigure an _existing_ Transport with a proxy. +func (c *Client) reconfigureProxyTransport(proxyURL string) func(*http.Request) (*url.URL, error) { + reqProxy := http.ProxyURL(nil) // set to no proxy first. + // check if our global variable has been set: + if len(proxy) > 0 { + tempURL, err := url.Parse(proxy) + if err != nil { + log.Printf("global proxy must be a valid URL; got %q which gives error: %s\n", proxy, err) + return nil + } + reqProxy = http.ProxyURL(tempURL) + } + // Second attempt: override it with the proxy setting for _this_ client instead: + if reqProxy == nil && len(c.proxy) > 0 { + tempURL, err := url.Parse(c.proxy) + if err != nil { + log.Printf("proxy set for this client must be a valid URL; got %q which gives error: %s", c.proxy, err) + return nil + } + reqProxy = http.ProxyURL(tempURL) + } + + // Third attempt: check if the passed proxyURL value is any good: + if reqProxy == nil && len(proxyURL) > 0 { + tempURL, err := url.Parse(proxyURL) + if err != nil { + log.Printf("proxyURL parameter passed for this client must be a valid URL; got %q which gives error: %s", proxyURL, err) + return nil + } + reqProxy = http.ProxyURL(tempURL) + } + + // Fourth attempt: fallback to environment variables instead + if reqProxy == nil { + reqProxy = http.ProxyFromEnvironment + } + // Note: if reqProxy is *still* `nil`, that's correct and appropriate for *no proxy* + return reqProxy +} + +// Tinify module initialisation. +// Currently just initialises tinifyProxyTransport as the default. +func init() { + // initialise the transport; instructions say that transports should be reused, not + // created on demand; by default, uses whatever proxies are defined on the environment. + tinifyProxyTransport = &http.Transport{ + Proxy: http.ProxyFromEnvironment, + // DialContext: defaultTransportDialContext(&net.Dialer{ + // Timeout: 30 * time.Second, + // KeepAlive: 30 * time.Second, + // }), + ForceAttemptHTTP2: true, + MaxIdleConns: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + } +} diff --git a/tinify/result.go b/tinify/result.go index 4f3512a..a7e28b9 100644 --- a/tinify/result.go +++ b/tinify/result.go @@ -1,60 +1,64 @@ package Tinify import ( - "io/ioutil" "net/http" "os" "path/filepath" - "strconv" ) +// Object returned by a call to the Tinify API. +// Note that the metadata is its own object and handled separately. type Result struct { - data []byte - *ResultMeta + data []byte // Raw image data. + *ResultMeta // Additional metadata returned by TinyPNG, namely, the file location generated. } +// Constructor for the `Result` object. func NewResult(meta http.Header, data []byte) *Result { r := new(Result) - r.ResultMeta = NewResultMeta(meta) + r.ResultMeta = NewResultMeta(meta) // also handle metadata. r.data = data return r } +// Returns the raw body of the call. func (r *Result) Data() []byte { return r.data } +// Retrieves the actual body of the call, which may be the raw image data. func (r *Result) ToBuffer() []byte { return r.Data() } +// Writes this object (an image file) to disk. func (r *Result) ToFile(path string) error { path, err := filepath.Abs(path) if err != nil { return err } - err = ioutil.WriteFile(path, r.data, os.ModePerm) + err = os.WriteFile(path, r.data, os.ModePerm) return err } +// Retrieves the size of the image file, as described in the header. +// Note that some web server implementations might not return this value, or it might +// be incorrectly calculated. func (r *Result) Size() int64 { - s := r.meta["Content-Length"] - if len(s) == 0 { - return 0 - } - - size, _ := strconv.Atoi(s[0]) - return int64(size) + return r.ResultMeta.size() } +// Returns the MIME type of this object, as retrieved from the headers in the result. func (r *Result) MediaType() string { - arr := r.meta["Content-Type"] - if len(arr) == 0 { - return "" - } - return arr[0] + return r.ResultMeta.mediaType() } +// Deprecated: Alias to `MediatType()` for backwards compatibility. func (r *Result) ContentType() string { - return r.MediaType() + return r.ResultMeta.mediaType() +} + +// Returns the numbr of compressions made so far. +func (r *Result) CompressionCount() int64 { + return r.ResultMeta.compressionCount() } diff --git a/tinify/result_meta.go b/tinify/result_meta.go index d550a2d..6fd512e 100644 --- a/tinify/result_meta.go +++ b/tinify/result_meta.go @@ -1,3 +1,6 @@ +// Retrieves TinyPNG-specific header tags, converting them to the proper values +// as returned by the API. ResultMeta **must** be initialised with the headers +// received from the API call. package Tinify import ( @@ -10,19 +13,22 @@ type ResultMeta struct { meta http.Header } +// NewResultMMeta creates a metadata object, reading the data func NewResultMeta(meta http.Header) *ResultMeta { r := new(ResultMeta) r.meta = meta return r } +// all other functions are private; public functions will be exposed only through the +// Result object. + func (r *ResultMeta) width() int64 { w := r.meta["Image-Width"] if len(w) == 0 { return 0 } width, _ := strconv.Atoi(w[0]) - return int64(width) } @@ -31,7 +37,6 @@ func (r *ResultMeta) height() int64 { if len(h) == 0 { return 0 } - height, _ := strconv.Atoi(h[0]) return int64(height) } @@ -43,3 +48,33 @@ func (r *ResultMeta) location() string { } return arr[0] } + +func (r *ResultMeta) size() int64 { + s := r.meta["Content-Length"] + if len(s) == 0 { + return 0 + } + + size, _ := strconv.Atoi(s[0]) // Atoi returns 0 if error + return int64(size) +} + +func (r *ResultMeta) mediaType() string { + arr := r.meta["Content-Type"] + if len(arr) == 0 { + return "" + } + return arr[0] +} + +// compressionCount returns how many times the user has invoked API calls. +// The number is supposed to be reset every month, and there is a limit on the number of free calls +// per month. Some operations will 'consume' more than one invocation. +func (r *ResultMeta) compressionCount() int64 { + c := r.meta["Compression-Count"] + if len(c) == 0 { + return 0 + } + compC, _ := strconv.Atoi(c[0]) + return int64(compC) +} diff --git a/tinify/source.go b/tinify/source.go index d3f187b..f818bee 100644 --- a/tinify/source.go +++ b/tinify/source.go @@ -1,44 +1,57 @@ package Tinify import ( + "encoding/json" "errors" - "io/ioutil" + "fmt" + "io" "net/http" + "os" ) const ( ResizeMethodScale = "scale" ResizeMethodFit = "fit" ResizeMethodCover = "cover" + ResizeMethodThumb = "thumb" // new method! ) type ResizeMethod string +// JSONified type for selecting resize options. type ResizeOption struct { Method ResizeMethod `json:"method"` - Width int64 `json:"width"` - Height int64 `json:"height"` + Width int64 `json:"width,omitempty"` + Height int64 `json:"height,omitempty"` } +// Main object type for returning a result. type Source struct { - url string - commands map[string]interface{} + url string // URL to retrieve from. + commands map[string]any // Commands passed to the Tinify API. + compressionCount string // This is the number of compressions made with this API key this month; may become an integer in the future, } -func newSource(url string, commands map[string]interface{}) *Source { +// JSONified type for error messages from the Tinify API, if present. +type ErrorMessage struct { + Error string `json:"error"` + Message string `json:"message"` +} + +func newSource(url string, commands map[string]any) *Source { s := new(Source) s.url = url if commands != nil { s.commands = commands } else { - s.commands = make(map[string]interface{}) + s.commands = make(map[string]any) } return s } func FromFile(path string) (s *Source, err error) { - buf, err := ioutil.ReadFile(path) + buf, err := os.ReadFile(path) if err != nil { return } @@ -58,12 +71,12 @@ func FromBuffer(buf []byte) (s *Source, err error) { func FromUrl(url string) (s *Source, err error) { if len(url) == 0 { - err = errors.New("url is required") + err = errors.New("URL is required") return } - body := map[string]interface{}{ - "source": map[string]interface{}{ + body := map[string]any{ + "source": map[string]any{ "url": url, }, } @@ -77,59 +90,201 @@ func FromUrl(url string) (s *Source, err error) { return } +// getSourceFromResponse tries to retrieve the URL that the Tinify API created to download the processed image. func getSourceFromResponse(response *http.Response) (s *Source, err error) { location := response.Header["Location"] url := "" - if len(location) > 0 { + if len(location) > 0 && response.StatusCode != http.StatusBadRequest { url = location[0] + } else { + return nil, fmt.Errorf("empty location and/or status error %d", response.StatusCode) } s = newSource(url, nil) + // Get number of compressions for this API key for this month, it comes in a header of its own. + // If the request didn't have such a header, that's ok, it'll just be an empty sring. + // (gwyneth 29250713) + s.compressionCount = response.Header["Compression-Count"][0] + return +} + +// ToFile is a wrapper that grabs the content of the result and writes to a file. +// The compression count is discarded. +// +// Obsolete: kept here only for compatibility purposes. +func (s *Source) ToFile(path string) (err error) { + _, err = s.ToFileC(path) return } -func (s *Source) ToFile(path string) error { +// ToFileC is a wrapper that grabs the content of the result and writes to a file. +// The compression count is returned as well. +// +// Supersedes `ToFile()`. +func (s *Source) ToFileC(path string) (int64, error) { result, err := s.toResult() if err != nil { - return err + return result.compressionCount(), err } - return result.ToFile(path) + return result.compressionCount(), result.ToFile(path) } +// ToBuffer extracts the raw data (an image) from the result. +// It's similar in concept to ToFile, but allows sending the data to STDOUT, for instance. +// (gwyneth 20230209)// +// +// Obsolete: kept here only for compatibility purposes. +func (s *Source) ToBuffer() (rawData []byte, err error) { + rawData, _, err = s.ToBufferC() + return +} + +// ToBufferC extracts the raw data (an image) from the result, also returning +// the compression count. +// +// Supersedes `ToBuffer()` +func (s *Source) ToBufferC() (rawData []byte, count int64, err error) { + result, err := s.toResult() + if err != nil { + return + } + + // Extract the compression count, even if the subsequent raw data + // extraction fails. + count = result.compressionCount() + + rawData = result.Data() // this is result.data, but may not be in the future, who knows? (gwyneth 20231209) + if len(rawData) == 0 { + err = fmt.Errorf("result returned zero bytes") + } + return +} + +// Checks errors in the list of commands for a resizing operation. func (s *Source) Resize(option *ResizeOption) error { if option == nil { - return errors.New("option is required") + return errors.New("option for resize is required") + } + // "scale" can only have width or height set, but not both! + if option.Method == ResizeMethodScale { + if option.Width != 0 && option.Height != 0 { + return errors.New("resize with scale method can only have either width or height set, but not both") + } + if option.Width == 0 && option.Height == 0 { + return errors.New("resize with scale method cannot have width and height both set to zero") + } + } else { + // for all other methods, the smallest possible value is 1! + if option.Width < 1 { + return errors.New("width must be >=1") + } + if option.Height < 1 { + return errors.New("height must be >=1") + } } - s.commands["resize"] = option return nil } +var ConvertMIMETypes = map[string]string{ + "png": "image/png", + "jpeg": "image/jpeg", + "webp": "image/webp", + "avif": "image/avif", +} + +// Extra type struct for JSONification purposes... +type ConvertOptions struct { + Type string `json:"type"` // can be image/png, etc. +} + +// Converts the image to one of several possible choices, returning the smallest. +func (s *Source) Convert(options []string) error { + if len(options) == 0 { + return errors.New("at least one option for convert is required") + } + // quick & dirty + allOpts := "" + for i, option := range options { + if i != 0 { + allOpts += "," + } + allOpts += ConvertMIMETypes[option] + } + // Should never happen... + if len(allOpts) == 0 { + return errors.New("concatenation of MIME types unexpectedly failed") + } + // Allocate some memory for the convert options, one never knows... + convertOptions := new(ConvertOptions) + convertOptions.Type = allOpts + s.commands["convert"] = convertOptions + + return nil +} + +// JSONified type for transform options, currently only "background" is supported. +type TransformOptions struct { + Background string `json:"background"` // "white", "black", or a hex colour. +} + +// Transforms the transparency colour into the desired background colour. +// Valid options are "white", "black", or a hex colour. +func (s *Source) Transform(option *TransformOptions) error { + if option == nil { + return errors.New("at least one option for transform is required") + } + + s.commands["transform"] = option + + return nil +} + +// toResult does the actual remote API call. It returns either a *Result or nil with an error +// message covering most possibilities of failure. +// The Tinify API specifies that all errors come as properly-formatted JSON, but we check even for that. func (s *Source) toResult() (r *Result, err error) { if len(s.url) == 0 { err = errors.New("url is empty") return } - //body := make([]byte, 0) - //if len(s.commands) > 0 { - // body, err = json.Marshal(s.commands) - // if err != nil { - // return - // } - //} response, err := GetClient().Request(http.MethodGet, s.url, s.commands) if err != nil { return } - data, err := ioutil.ReadAll(response.Body) + // NOTE: if the request succeeds, but the API found an error, it returns with a JSON + // indicating the error. + data, err := io.ReadAll(response.Body) + + // did we get an error code from the API call? + // Note: we consider all JSON answers as "errors", evn if the API doesn't mandate that. + if response.StatusCode >= 400 || response.Header.Get("Content-Type") == "application/json" { + // we got an error but couldn't retrieve any data: + if err != nil { + // we can only retrieve the Status line with a short error + return nil, errors.New(response.Status) + } + // otherwise, we can return the error by unmarshalling the received JSONified error: + var errMsg ErrorMessage + + if jErr := json.Unmarshal(data, &errMsg); jErr != nil { + // Unmarshalling failed, but we still have + return nil, fmt.Errorf("Tinify API call failed, HTTP status was %q, couldn't unmarshal JSON body: error %q", response.Status, jErr) + } + // We've successfully decoded the error message, so we can return it: + return nil, fmt.Errorf("Tinify API call failed, HTTP status was %q. Error: %s Message: %s", + response.Status, errMsg.Error, errMsg.Message) + } + // At this stage, the only error we have is from a failed decoded body data. if err != nil { return } + // No errors found. The result can be sent back to the caller. r = NewResult(response.Header, data) return } diff --git a/tinify/tinify.go b/tinify/tinify.go index 12afb20..576f3e4 100644 --- a/tinify/tinify.go +++ b/tinify/tinify.go @@ -1,27 +1,42 @@ +// Unofficial implementation of the Tinify API for image manipulation. +// +// Author: "gwpp" +// Email: "ganwenpeng1993@163.com", package Tinify -import "errors" +import ( + "errors" +) -const VERSION = "1.0" +const VERSION = "v0.2.0" // using semantic versioning; 1.0 is considered "stable"... var ( - key string - client *Client + key string // Tinify API Key, as obtained through https://tinypng.com/developers. + client *Client // Default Tinify API client. + proxy string // Proxy used just for the Tinify API. ) -func SetKey(set_key string) { - key = set_key +// Sets the global Tinify API key for the module. +// NOTE: This function allows `Tinify.SetKey()` to be valid Go code. +func SetKey(setKey string) { + key = setKey +} + +// Go will automatically use proxies, but that's fine, we can still override them. +func Proxy(setProxy string) { + proxy = setProxy } +// Returns a new Client after checking that the stored API key is valid. func GetClient() *Client { if len(key) == 0 { - panic(errors.New("Provide an API key with Tinify.setKey(key string)")) + panic(errors.New("provide an API key with Tinify.SetKey(key string)")) } if client == nil { c, err := NewClient(key) if err != nil { - panic(errors.New("Provide an API key with Tinify.setKey(key string)")) + panic(errors.New("provide an API key with Tinify.SetKey(key string)")) } client = c } diff --git a/tinify_test.go b/tinify_test.go index 43ebb09..8a4a449 100644 --- a/tinify_test.go +++ b/tinify_test.go @@ -1,35 +1,35 @@ +// Test suite for main functionality + package main import ( + "os" "testing" - "io/ioutil" - "github.com/gwpp/tinify-go/tinify" + _ "github.com/joho/godotenv/autoload" ) -const Key = "rcPZm3Zrg_1DbjYtV6AXM_-53Jg9wuWB" - func TestCompressFromFile(t *testing.T) { - Tinify.SetKey(Key) - source, err := Tinify.FromFile("./test.jpg") + Tinify.SetKey(os.Getenv("TINIFY_API_KEY")) + source, err := Tinify.FromFile("./testdata/input/test.jpg") if err != nil { t.Error(err) return } - - err = source.ToFile("./test_output/CompressFromFile.jpg") + var tokens int64 + tokens, err = source.ToFileC("./testdata/output/CompressFromFile.jpg") if err != nil { t.Error(err) return } - t.Log("Compress successful") + t.Logf("Compress successful, %d tokens consumed", tokens) } func TestCompressFromBuffer(t *testing.T) { - Tinify.SetKey(Key) + Tinify.SetKey(os.Getenv("TINIFY_API_KEY")) - buf, err := ioutil.ReadFile("./test.jpg") + buf, err := os.ReadFile("./testdata/input/test.jpg") if err != nil { t.Error(err) return @@ -39,34 +39,35 @@ func TestCompressFromBuffer(t *testing.T) { t.Error(err) return } - - err = source.ToFile("./test_output/CompressFromBuffer.jpg") + var tokens int64 + tokens, err = source.ToFileC("./testdata/output/CompressFromBuffer.jpg") if err != nil { t.Error(err) return } - t.Log("Compress successful") + t.Logf("Compress successful, %d tokens consumed", tokens) } func TestCompressFromUrl(t *testing.T) { - Tinify.SetKey(Key) + Tinify.SetKey(os.Getenv("TINIFY_API_KEY")) url := "http://pic.tugou.com/realcase/1481255483_7311782.jpg" source, err := Tinify.FromUrl(url) if err != nil { t.Error(err) return } - err = source.ToFile("./test_output/CompressFromUrl.jpg") + var tokens int64 + tokens, err = source.ToFileC("./testdata/output/CompressFromUrl.jpg") if err != nil { t.Error(err) return } - t.Log("Compress successful") + t.Logf("Compress successful, %d tokens consumed", tokens) } func TestResizeFromFile(t *testing.T) { - Tinify.SetKey(Key) - source, err := Tinify.FromFile("./test.jpg") + Tinify.SetKey(os.Getenv("TINIFY_API_KEY")) + source, err := Tinify.FromFile("./testdata/input/test.jpg") if err != nil { t.Error(err) return @@ -82,19 +83,20 @@ func TestResizeFromFile(t *testing.T) { return } - err = source.ToFile("./test_output/ResizeFromFile.jpg") + var tokens int64 + tokens, err = source.ToFileC("./testdata/output/ResizeFromFile.jpg") if err != nil { t.Error(err) return } - t.Log("Resize successful") + t.Logf("Resize successful, %d tokens consumed", tokens) } func TestResizeFromBuffer(t *testing.T) { - Tinify.SetKey(Key) + Tinify.SetKey(os.Getenv("TINIFY_API_KEY")) - buf, err := ioutil.ReadFile("./test.jpg") + buf, err := os.ReadFile("./testdata/input/test.jpg") if err != nil { t.Error(err) return @@ -114,16 +116,75 @@ func TestResizeFromBuffer(t *testing.T) { return } - err = source.ToFile("./test_output/ResizesFromBuffer.jpg") + var tokens int64 + tokens, err = source.ToFileC("./testdata/output/ResizesFromBuffer.jpg") + if err != nil { + t.Error(err) + return + } + t.Logf("Resize successful, %d tokens consumed", tokens) +} + +// This ests if we're using scale with both width and height set. +func TestResizeFromBufferScaleWidthAndHeight(t *testing.T) { + Tinify.SetKey(os.Getenv("TINIFY_API_KEY")) + + buf, err := os.ReadFile("./testdata/input/test.jpg") + if err != nil { + t.Error(err) + return + } + source, err := Tinify.FromBuffer(buf) if err != nil { t.Error(err) return } - t.Log("Resize successful") + + err = source.Resize(&Tinify.ResizeOption{ + Method: Tinify.ResizeMethodScale, + Height: 256, + Width: 128, + }) + // inverse logic, this *must* fail! + if err == nil { + t.Error("Resize with scale cannot have both width and height set!") + return + } + + t.Log("Resize with scale using width and height both set was correctly flagged with error", err) +} + +// This ests if we're using scale with both width and height set to zero. +func TestResizeFromBufferScaleBothDimensionsZero(t *testing.T) { + Tinify.SetKey(os.Getenv("TINIFY_API_KEY")) + + buf, err := os.ReadFile("./testdata/input/test.jpg") + if err != nil { + t.Error(err) + return + } + source, err := Tinify.FromBuffer(buf) + if err != nil { + t.Error(err) + return + } + + err = source.Resize(&Tinify.ResizeOption{ + Method: Tinify.ResizeMethodScale, + /* Height: 256, + Width: 128,*/ + }) + // inverse logic, this *must* fail! + if err == nil { + t.Error("Resize with scale cannot have both width and height set to zero!") + return + } + + t.Log("Resize with scale using width and height both set to zero was correctly flagged with error", err) } func TestResizeFromUrl(t *testing.T) { - Tinify.SetKey(Key) + Tinify.SetKey(os.Getenv("TINIFY_API_KEY")) url := "http://pic.tugou.com/realcase/1481255483_7311782.jpg" source, err := Tinify.FromUrl(url) if err != nil { @@ -141,10 +202,11 @@ func TestResizeFromUrl(t *testing.T) { return } - err = source.ToFile("./test_output/ResizeFromUrl.jpg") + var tokens int64 + tokens, err = source.ToFileC("./testdata/output/ResizeFromUrl.jpg") if err != nil { t.Error(err) return } - t.Log("Resize successful") + t.Logf("Resize successful, %d tokens consumed", tokens) }