├── .gitattributes ├── .github ├── dependabot.yml └── workflows │ ├── coverage.yml │ ├── dependabot.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── convert.go ├── convert_test.go ├── converter ├── go.mod ├── go.sum ├── main.go └── misc.go ├── example_test.go ├── format.go ├── format_test.go ├── go.mod ├── go.sum ├── gray.go ├── gray_test.go ├── imaging.go ├── option.go ├── option_test.go ├── resize.go ├── resize_test.go ├── split.go ├── split_test.go ├── testdata ├── video-001.bmp ├── video-001.gif ├── video-001.jpg ├── video-001.pdf ├── video-001.png ├── video-001.tif └── video-001.webp ├── watermark.go └── watermark_test.go /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | 8 | - package-ecosystem: "gomod" 9 | directory: "/converter" 10 | schedule: 11 | interval: "daily" 12 | 13 | - package-ecosystem: "github-actions" 14 | directory: "/" 15 | schedule: 16 | interval: "daily" 17 | -------------------------------------------------------------------------------- /.github/workflows/coverage.yml: -------------------------------------------------------------------------------- 1 | name: Coverage 2 | 3 | on: 4 | workflow_run: 5 | workflows: [ Test ] 6 | branches: [ main ] 7 | types: 8 | - completed 9 | 10 | jobs: 11 | coverage: 12 | if: ${{ github.event.workflow_run.conclusion == 'success' }} 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout Code 16 | uses: actions/checkout@v4 17 | 18 | - name: Setup Go 19 | uses: actions/setup-go@v5 20 | with: 21 | go-version: stable 22 | 23 | - name: Send Coverage 24 | env: 25 | COVERALLS_TOKEN: ${{ secrets.GITHUB_TOKEN }} 26 | run: | 27 | go test -race -covermode atomic -coverprofile=covprofile ./... 28 | go install github.com/mattn/goveralls@latest 29 | goveralls -coverprofile=covprofile -service=github 30 | -------------------------------------------------------------------------------- /.github/workflows/dependabot.yml: -------------------------------------------------------------------------------- 1 | name: Dependabot 2 | 3 | on: 4 | pull_request_target: 5 | 6 | jobs: 7 | test: 8 | if: ${{ github.actor == 'dependabot[bot]' }} 9 | runs-on: ${{ matrix.os }} 10 | strategy: 11 | matrix: 12 | os: [ windows-latest, ubuntu-latest, macos-latest ] 13 | steps: 14 | - name: Checkout Code 15 | uses: actions/checkout@v4 16 | with: 17 | ref: ${{ github.event.pull_request.head.sha }} 18 | 19 | - name: Setup Go 20 | uses: actions/setup-go@v5 21 | with: 22 | go-version: stable 23 | 24 | - name: Test Code 25 | id: test 26 | run: | 27 | go build -v ./... 28 | go test -v -race ./... 29 | cd converter 30 | go mod tidy 31 | go build -v ./... 32 | go clean 33 | cd .. 34 | 35 | - name: Check New Data 36 | id: check 37 | if: matrix.os == 'ubuntu-latest' 38 | run: | 39 | git config user.name "GitHub Actions" 40 | git config user.email "41898282+github-actions[bot]@users.noreply.github.com" 41 | git add . 42 | git diff-index --quiet HEAD || echo "new_data=1" >> $GITHUB_OUTPUT 43 | echo "date=$(TZ=PRC date +'%Y-%m-%d')" >> $GITHUB_OUTPUT 44 | 45 | - name: Commit 46 | if: steps.check.outputs.new_data == 1 47 | run: | 48 | git commit -m ${{ steps.check.outputs.date }} 49 | git push origin HEAD:${{ github.event.pull_request.head.ref }} 50 | 51 | merge: 52 | if: ${{ github.actor == 'dependabot[bot]' }} 53 | runs-on: ubuntu-latest 54 | needs: test 55 | permissions: 56 | pull-requests: write 57 | contents: write 58 | steps: 59 | - uses: actions/checkout@v4 60 | - uses: nick-invision/retry@v3 61 | with: 62 | timeout_minutes: 60 63 | max_attempts: 5 64 | retry_wait_seconds: 60 65 | retry_on: error 66 | command: gh pr merge --auto --squash ${{ github.event.pull_request.html_url }} 67 | env: 68 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 69 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: 6 | - created 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout Code 13 | uses: actions/checkout@v4 14 | 15 | - name: Setup Go 16 | uses: actions/setup-go@v5 17 | with: 18 | go-version: stable 19 | 20 | - name: Build 21 | run: | 22 | cd converter 23 | go mod tidy 24 | GOOS=windows go build -ldflags "-s -w" -o ../converter.exe 25 | 26 | - name: Upload Release Asset 27 | uses: shogo82148/actions-upload-release-asset@v1 28 | with: 29 | upload_url: ${{ github.event.release.upload_url }} 30 | asset_path: converter.exe 31 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | test: 11 | if: ${{ github.actor != 'dependabot[bot]' }} 12 | runs-on: ${{ matrix.os }} 13 | strategy: 14 | matrix: 15 | os: [ windows-latest, ubuntu-latest, macos-latest ] 16 | steps: 17 | - name: Checkout Code 18 | uses: actions/checkout@v4 19 | 20 | - name: Setup Go 21 | uses: actions/setup-go@v5 22 | with: 23 | go-version: stable 24 | 25 | - name: Test Code 26 | run: | 27 | go build -v ./... 28 | go test -v -race ./... 29 | cd converter 30 | go mod tidy 31 | go build -v ./... 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | 17 | converter 18 | *.ini 19 | *.log 20 | output 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 sunshineplan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Image Converter 2 | 3 | [![GoDev](https://img.shields.io/static/v1?label=godev&message=reference&color=00add8)][godev] 4 | [![Go](https://github.com/sunshineplan/imgconv/workflows/Test/badge.svg)][actions] 5 | [![CoverageStatus](https://coveralls.io/repos/github/sunshineplan/imgconv/badge.svg?branch=main&service=github)][coveralls] 6 | [![GoReportCard](https://goreportcard.com/badge/github.com/sunshineplan/imgconv)][goreportcard] 7 | 8 | [godev]: https://pkg.go.dev/github.com/sunshineplan/imgconv 9 | [actions]: https://github.com/sunshineplan/imgconv/actions "GitHub Actions Page" 10 | [coveralls]: https://coveralls.io/github/sunshineplan/imgconv?branch=main 11 | [goreportcard]: https://goreportcard.com/report/github.com/sunshineplan/imgconv 12 | 13 | Package imgconv provides basic image processing functions (resize, add watermark, format converter.). 14 | 15 | All the image processing functions provided by the package accept any image type that implements `image.Image` interface 16 | as an input, include jpg(jpeg), png, gif, tif(tiff), bmp, webp and pdf. 17 | 18 | ## Installation 19 | 20 | go get -u github.com/sunshineplan/imgconv 21 | 22 | ## License 23 | 24 | [The MIT License (MIT)](https://raw.githubusercontent.com/sunshineplan/imgconv/main/LICENSE) 25 | 26 | ## Credits 27 | 28 | This repo relies on the following third-party projects: 29 | 30 | * [disintegration/imaging](https://github.com/disintegration/imaging) 31 | * [pdfcpu/pdfcpu](https://github.com/pdfcpu/pdfcpu) 32 | * [hhrutter/tiff](https://github.com/hhrutter/tiff) 33 | * [HugoSmits86/nativewebp](github.com/HugoSmits86/nativewebp) 34 | 35 | ## Usage examples 36 | 37 | A few usage examples can be found below. See the documentation for the full list of supported functions. 38 | 39 | ### Image resizing 40 | 41 | ```go 42 | // Resize srcImage to size = 128x128px. 43 | dstImage128 := imgconv.Resize(srcImage, &imgconv.ResizeOption{Width: 128, Height: 128}) 44 | 45 | // Resize srcImage to width = 800px preserving the aspect ratio. 46 | dstImage800 := imgconv.Resize(srcImage, &imgconv.ResizeOption{Width: 800}) 47 | 48 | // Resize srcImage to 50% size preserving the aspect ratio. 49 | dstImagePercent50 := imgconv.Resize(srcImage, &imgconv.ResizeOption{Percent: 50}) 50 | ``` 51 | 52 | ### Image splitting 53 | 54 | ```go 55 | // Split srcImage into 3 parts horizontally. 56 | imgs, err := imgconv.Split(srcImage, 3, imgconv.SplitHorizontalMode) 57 | 58 | // Split srcImage into 3 parts vertically. 59 | imgs, err := imgconv.Split(srcImage, 3, imgconv.SplitVerticalMode) 60 | ``` 61 | 62 | ### Add watermark 63 | 64 | ```go 65 | // srcImage add a watermark at randomly position. 66 | dstImage := imgconv.Watermark(srcImage, &WatermarkOption{Mark: markImage, Opacity: 128, Random: true}) 67 | 68 | // srcImage add a watermark at fixed position with offset. 69 | dstImage := imgconv.Watermark(srcImage, &WatermarkOption{Mark: markImage, Opacity: 128, Offset: image.Pt(5, 5)}) 70 | ``` 71 | 72 | ### Format convert 73 | 74 | ```go 75 | // Convert srcImage to dst with jpg format. 76 | imgconv.Write(dstWriter, srcImage, &imgconv.FormatOption{Format: imgconv.JPEG}) 77 | ``` 78 | 79 | ## Example code 80 | 81 | ```go 82 | package main 83 | 84 | import ( 85 | "io" 86 | "log" 87 | 88 | "github.com/sunshineplan/imgconv" 89 | ) 90 | 91 | func main() { 92 | // Open a test image. 93 | src, err := imgconv.Open("testdata/video-001.png") 94 | if err != nil { 95 | log.Fatalf("failed to open image: %v", err) 96 | } 97 | 98 | // Resize the image to width = 200px preserving the aspect ratio. 99 | mark := imgconv.Resize(src, &imgconv.ResizeOption{Width: 200}) 100 | 101 | // Add random watermark set opacity = 128. 102 | dst := imgconv.Watermark(src, &imgconv.WatermarkOption{Mark: mark, Opacity: 128, Random: true}) 103 | 104 | // Write the resulting image as TIFF. 105 | if err := imgconv.Write(io.Discard, dst, &imgconv.FormatOption{Format: imgconv.TIFF}); err != nil { 106 | log.Fatalf("failed to write image: %v", err) 107 | } 108 | 109 | // Split the image into 3 parts horizontally. 110 | imgs, err := imgconv.Split(src, 3, imgconv.SplitHorizontalMode) 111 | if err != nil { 112 | log.Fatalf("failed to split image: %v", err) 113 | } 114 | } 115 | ``` 116 | -------------------------------------------------------------------------------- /convert.go: -------------------------------------------------------------------------------- 1 | package imgconv 2 | 3 | import ( 4 | "image" 5 | "io" 6 | "os" 7 | ) 8 | 9 | type decodeConfig struct { 10 | autoOrientation bool 11 | } 12 | 13 | var defaultDecodeConfig = decodeConfig{ 14 | autoOrientation: true, 15 | } 16 | 17 | // DecodeOption sets an optional parameter for the Decode and Open functions. 18 | type DecodeOption func(*decodeConfig) 19 | 20 | // AutoOrientation returns a DecodeOption that sets the auto-orientation mode. 21 | // If auto-orientation is enabled, the image will be transformed after decoding 22 | // according to the EXIF orientation tag (if present). By default it's enabled. 23 | func AutoOrientation(enabled bool) DecodeOption { 24 | return func(c *decodeConfig) { 25 | c.autoOrientation = enabled 26 | } 27 | } 28 | 29 | // Decode reads an image from r. 30 | // If want to use custom image format packages which were registered in image package, please 31 | // make sure these custom packages imported before importing imgconv package. 32 | func Decode(r io.Reader, opts ...DecodeOption) (image.Image, error) { 33 | cfg := defaultDecodeConfig 34 | for _, option := range opts { 35 | option(&cfg) 36 | } 37 | 38 | return decode(r, autoOrientation(cfg.autoOrientation)) 39 | } 40 | 41 | // DecodeConfig decodes the color model and dimensions of an image that has been encoded in a 42 | // registered format. The string returned is the format name used during format registration. 43 | func DecodeConfig(r io.Reader) (image.Config, string, error) { 44 | return image.DecodeConfig(r) 45 | } 46 | 47 | // Open loads an image from file. 48 | func Open(file string, opts ...DecodeOption) (image.Image, error) { 49 | f, err := os.Open(file) 50 | if err != nil { 51 | return nil, err 52 | } 53 | defer f.Close() 54 | 55 | return Decode(f, opts...) 56 | } 57 | 58 | // Write image according format option 59 | func Write(w io.Writer, base image.Image, option *FormatOption) error { 60 | return option.Encode(w, base) 61 | } 62 | 63 | // Save saves image according format option 64 | func Save(output string, base image.Image, option *FormatOption) error { 65 | f, err := os.Create(output) 66 | if err != nil { 67 | return err 68 | } 69 | defer f.Close() 70 | 71 | return option.Encode(f, base) 72 | } 73 | -------------------------------------------------------------------------------- /convert_test.go: -------------------------------------------------------------------------------- 1 | package imgconv 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "os" 7 | "testing" 8 | ) 9 | 10 | func TestDecodeWrite(t *testing.T) { 11 | var formats = []string{ 12 | "jpg", 13 | "png", 14 | "gif", 15 | "tif", 16 | "bmp", 17 | "webp", 18 | "pdf", 19 | } 20 | 21 | for _, i := range formats { 22 | b, err := os.ReadFile("testdata/video-001." + i) 23 | if err != nil { 24 | t.Fatal(err) 25 | } 26 | 27 | img, err := Decode(bytes.NewBuffer(b)) 28 | if err != nil { 29 | t.Fatal("Failed to decode", i) 30 | } 31 | 32 | if err := Write(io.Discard, img, &FormatOption{}); err != nil { 33 | t.Fatal("Failed to write", i) 34 | } 35 | 36 | if _, _, err := DecodeConfig(bytes.NewBuffer(b)); err != nil { 37 | t.Fatal("Failed to decode", i, "config") 38 | } 39 | } 40 | 41 | if _, err := Decode(bytes.NewBufferString("Hello")); err == nil { 42 | t.Fatal("Decode string want error") 43 | } 44 | } 45 | 46 | func TestOpenSave(t *testing.T) { 47 | if _, err := Open("/invalid/path"); err == nil { 48 | t.Error("Open invalid path want error") 49 | } 50 | 51 | if _, err := Open("go.mod"); err == nil { 52 | t.Error("Open invalid image want error") 53 | } 54 | 55 | img, err := Open("testdata/video-001.png") 56 | if err != nil { 57 | t.Fatal("Fail to open image", err) 58 | } 59 | 60 | if err := Save("/invalid/path", img, defaultFormat); err == nil { 61 | t.Fatal("Save invalid path want error") 62 | } 63 | 64 | if err := Save("testdata/tmp", img, defaultFormat); err != nil { 65 | t.Fatal("Fail to save image", err) 66 | } 67 | if err := os.Remove("testdata/tmp"); err != nil { 68 | t.Fatal(err) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /converter/go.mod: -------------------------------------------------------------------------------- 1 | module converter 2 | 3 | go 1.24 4 | 5 | require ( 6 | github.com/sunshineplan/imgconv v0.0.0-00010101000000-000000000000 7 | github.com/sunshineplan/tiff v0.0.0-20220128141034-29b9d69bd906 8 | github.com/sunshineplan/utils v0.1.76 9 | github.com/sunshineplan/workers v1.0.5 10 | ) 11 | 12 | require ( 13 | github.com/HugoSmits86/nativewebp v1.2.0 // indirect 14 | github.com/hhrutter/lzw v1.0.0 // indirect 15 | github.com/hhrutter/tiff v1.0.1 // indirect 16 | github.com/mattn/go-runewidth v0.0.16 // indirect 17 | github.com/pdfcpu/pdfcpu v0.9.1 // indirect 18 | github.com/pkg/errors v0.9.1 // indirect 19 | github.com/rivo/uniseg v0.4.7 // indirect 20 | github.com/sunshineplan/pdf v1.0.8 // indirect 21 | golang.org/x/image v0.27.0 // indirect 22 | golang.org/x/sync v0.14.0 // indirect 23 | golang.org/x/text v0.25.0 // indirect 24 | gopkg.in/yaml.v2 v2.4.0 // indirect 25 | ) 26 | 27 | replace github.com/sunshineplan/imgconv => ../ 28 | -------------------------------------------------------------------------------- /converter/go.sum: -------------------------------------------------------------------------------- 1 | github.com/HugoSmits86/nativewebp v1.2.0 h1:XJtXeTg7FsOi9VB1elQYZy3n6VjYLqofSr3gGRLUOp4= 2 | github.com/HugoSmits86/nativewebp v1.2.0/go.mod h1:YNQuWenlVmSUUASVNhTDwf4d7FwYQGbGhklC8p72Vr8= 3 | github.com/hhrutter/lzw v1.0.0 h1:laL89Llp86W3rRs83LvKbwYRx6INE8gDn0XNb1oXtm0= 4 | github.com/hhrutter/lzw v1.0.0/go.mod h1:2HC6DJSn/n6iAZfgM3Pg+cP1KxeWc3ezG8bBqW5+WEo= 5 | github.com/hhrutter/tiff v1.0.1 h1:MIus8caHU5U6823gx7C6jrfoEvfSTGtEFRiM8/LOzC0= 6 | github.com/hhrutter/tiff v1.0.1/go.mod h1:zU/dNgDm0cMIa8y8YwcYBeuEEveI4B0owqHyiPpJPHc= 7 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 8 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 9 | github.com/pdfcpu/pdfcpu v0.9.1 h1:q8/KlBdHjkE7ZJU4ofhKG5Rjf7M6L324CVM6BMDySao= 10 | github.com/pdfcpu/pdfcpu v0.9.1/go.mod h1:fVfOloBzs2+W2VJCCbq60XIxc3yJHAZ0Gahv1oO0gyI= 11 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 12 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 13 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 14 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 15 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 16 | github.com/sunshineplan/pdf v1.0.8 h1:5/HWBjgPX/3WGe+GHkC0KxUS43pd/jqfZ0F+u9pDiG8= 17 | github.com/sunshineplan/pdf v1.0.8/go.mod h1:pvucuW/bqRdicCSIm3RNjnyYXkNFXVIvJ8ePLCeK2s8= 18 | github.com/sunshineplan/tiff v0.0.0-20220128141034-29b9d69bd906 h1:+yYRCj+PGQNnnen4+/Q7eKD2J87RJs+O39bjtHhPauk= 19 | github.com/sunshineplan/tiff v0.0.0-20220128141034-29b9d69bd906/go.mod h1:O+Ar7ouRbdfxLgoZLFz447/dvdM1NVKk1VpOQaijvAU= 20 | github.com/sunshineplan/utils v0.1.76 h1:FYdXgKi1S7yCnWRYnP7spmOakcFK3Fhkakf4G0kA2sg= 21 | github.com/sunshineplan/utils v0.1.76/go.mod h1:R7MInPRKnExzzNGJ9qY1W0+11P/3jc6IO5ICuGDPA1w= 22 | github.com/sunshineplan/workers v1.0.5 h1:yvBGRGPFt0J2wxKfQRTlP9ziE0aSIHeJOgjOdxl5BIQ= 23 | github.com/sunshineplan/workers v1.0.5/go.mod h1:LOuWiwDqqs8b5A+wbwSl7nsqlQmj9mQCrHqX8F4sXdk= 24 | golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w= 25 | golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g= 26 | golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= 27 | golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 28 | golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= 29 | golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= 30 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 31 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 32 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 33 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 34 | -------------------------------------------------------------------------------- /converter/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "flag" 7 | "fmt" 8 | "image" 9 | "image/color" 10 | "io/fs" 11 | "log/slog" 12 | "os" 13 | "path/filepath" 14 | "time" 15 | 16 | "github.com/sunshineplan/imgconv" 17 | "github.com/sunshineplan/utils/flags" 18 | "github.com/sunshineplan/utils/log" 19 | "github.com/sunshineplan/utils/progressbar" 20 | "github.com/sunshineplan/workers" 21 | ) 22 | 23 | var ( 24 | src = flag.String("src", "", "") 25 | dst = flag.String("dst", "output", "") 26 | test = flag.Bool("test", false, "") 27 | force = flag.Bool("force", false, "") 28 | pdf = flag.Bool("pdf", false, "") 29 | whiteBackground = flag.Bool("white-background", false, "") 30 | gray = flag.Bool("gray", false, "") 31 | quality = flag.Int("quality", 75, "") 32 | autoOrientation = flag.Bool("auto-orientation", false, "") 33 | useExtendedFormat = flag.Bool("use-extended-format", false, "") 34 | watermark = flag.String("watermark", "", "") 35 | opacity = flag.Uint("opacity", 128, "") 36 | random = flag.Bool("random", false, "") 37 | offsetX = flag.Int("x", 0, "") 38 | offsetY = flag.Int("y", 0, "") 39 | width = flag.Int("width", 0, "") 40 | height = flag.Int("height", 0, "") 41 | percent = flag.Float64("percent", 0, "") 42 | worker = flag.Int("worker", 5, "") 43 | quiet = flag.Bool("q", false, "") 44 | debug = flag.Bool("debug", false, "") 45 | 46 | format imgconv.Format 47 | compression imgconv.TIFFCompression 48 | ) 49 | 50 | func usage() { 51 | fmt.Fprintf(flag.CommandLine.Output(), "Usage of %s:\n", os.Args[0]) 52 | fmt.Println(` 53 | --src 54 | source file or directory 55 | --dst 56 | destination directory (default: output) 57 | --test 58 | test source file only, don't convert (default: false) 59 | --force 60 | force overwrite (default: false) 61 | --pdf 62 | convert pdf source (default: false) 63 | --format 64 | output format (jpg, jpeg, png, gif, tif, tiff, bmp, pdf and webp are supported, default: jpg) 65 | --white-background 66 | use white color for transparent background (default: false) 67 | --gray 68 | convert to grayscale (default: false) 69 | --quality 70 | set jpeg or pdf quality (range 1-100, default: 75) 71 | --compression 72 | set tiff compression type (none, deflate, default: deflate) 73 | --auto-orientation 74 | auto orientation (default: false) 75 | --use-extended-format 76 | set webp to use extended format (default: false) 77 | --watermark 78 | watermark path 79 | --opacity 80 | watermark opacity (range 0-255, default: 128) 81 | --random 82 | random watermark (default: false) 83 | -x, y 84 | fixed watermark center offset X, Y value. Only used in no random mode. 85 | --width 86 | resize width, if one of width or height is 0, the image aspect ratio is preserved. 87 | --height 88 | resize height, if one of width or height is 0, the image aspect ratio is preserved. 89 | --percent 90 | resize percent, only when both of width and height are 0.`) 91 | } 92 | 93 | func main() { 94 | var code int 95 | defer func() { 96 | if err := recover(); err != nil { 97 | if err == flag.ErrHelp { 98 | return 99 | } 100 | log.Error("Panic", "error", err) 101 | if code == 0 { 102 | code = 1 103 | } 104 | } 105 | fmt.Println("Press enter key to exit . . .") 106 | fmt.Scanln() 107 | os.Exit(code) 108 | }() 109 | 110 | self, err := os.Executable() 111 | if err != nil { 112 | log.Error("Failed to get self path", "error", err) 113 | code = 1 114 | return 115 | } 116 | 117 | flag.CommandLine.Init(os.Args[0], flag.PanicOnError) 118 | flag.Usage = usage 119 | flag.TextVar(&format, "format", imgconv.JPEG, "") 120 | flag.TextVar(&compression, "compression", imgconv.TIFFDeflate, "") 121 | flags.SetConfigFile(filepath.Join(filepath.Dir(self), "config.ini")) 122 | flags.Parse() 123 | 124 | log.SetOutput(filepath.Join(filepath.Dir(self), fmt.Sprintf("convert%s.log", time.Now().Format("20060102150405"))), os.Stdout) 125 | if *debug { 126 | log.SetLevel(slog.LevelDebug) 127 | } 128 | 129 | srcInfo, err := os.Stat(*src) 130 | if err != nil { 131 | log.Error("Failed to get FileInfo of source", "source", *src, "error", err) 132 | code = 1 133 | return 134 | } 135 | 136 | if *test { 137 | switch { 138 | case srcInfo.Mode().IsDir(): 139 | images := loadImages(*src, *pdf) 140 | total := len(images) 141 | log.Println("Total images:", total) 142 | pb := progressbar.New(total) 143 | pb.Start() 144 | workers.Workers(*worker).Run(context.Background(), workers.SliceJob(images, func(_ int, image string) { 145 | defer pb.Add(1) 146 | if _, err := open(image); err != nil { 147 | log.Error("Bad image", "image", image, "error", err) 148 | } 149 | })) 150 | pb.Done() 151 | case srcInfo.Mode().IsRegular(): 152 | if _, err := open(*src); err != nil { 153 | log.Error("Bad image", "image", *src, "error", err) 154 | } 155 | default: 156 | log.Error("Unknown source mode", "mode", srcInfo.Mode()) 157 | code = 1 158 | } 159 | return 160 | } 161 | 162 | task := imgconv.NewOptions() 163 | 164 | var opts []imgconv.EncodeOption 165 | if format == imgconv.JPEG || format == imgconv.PDF { 166 | opts = append(opts, imgconv.Quality(*quality)) 167 | } 168 | if format == imgconv.TIFF { 169 | opts = append(opts, imgconv.TIFFCompressionType(compression)) 170 | } 171 | if format == imgconv.WEBP { 172 | opts = append(opts, imgconv.WEBPUseExtendedFormat(*useExtendedFormat)) 173 | } 174 | if *whiteBackground { 175 | opts = append(opts, imgconv.BackgroundColor(color.White)) 176 | } 177 | task.SetFormat(format, opts...) 178 | 179 | if *gray { 180 | task.SetGray(true) 181 | } 182 | if *watermark != "" { 183 | mark, err := imgconv.Open(*watermark) 184 | if err != nil { 185 | log.Error("Failed to open watermark", "watermark", *watermark, "error", err) 186 | code = 1 187 | return 188 | } 189 | task.SetWatermark(mark, *opacity) 190 | task.Watermark.SetRandom(*random).SetOffset(image.Point{X: *offsetX, Y: *offsetY}) 191 | } 192 | if *width != 0 || *height != 0 || *percent != 0 { 193 | task.SetResize(*width, *height, *percent) 194 | } 195 | 196 | dstInfo, err := os.Stat(*dst) 197 | if err != nil { 198 | if errors.Is(err, fs.ErrNotExist) { 199 | if err := os.MkdirAll(*dst, 0755); err != nil { 200 | log.Error("Failed to create directory for destination", "destination", *dst, "error", err) 201 | code = 1 202 | return 203 | } 204 | dstInfo, _ = os.Stat(*dst) 205 | } else { 206 | log.Error("Failed to get FileInfo of destination", "destination", *dst, "error", err) 207 | code = 1 208 | return 209 | } 210 | } 211 | 212 | switch { 213 | case srcInfo.Mode().IsDir(): 214 | if !dstInfo.Mode().IsDir() { 215 | log.Error("Destination is not a directory", "destination", *dst) 216 | code = 1 217 | return 218 | } 219 | images := loadImages(*src, *pdf) 220 | total := len(images) 221 | log.Println("Total images:", total) 222 | pb := progressbar.New(total) 223 | if !*quiet { 224 | pb.Start() 225 | } 226 | workers.Workers(*worker).Run(context.Background(), workers.SliceJob(images, func(_ int, image string) { 227 | defer pb.Add(1) 228 | rel, err := filepath.Rel(*src, image) 229 | if err != nil { 230 | log.Error("Failed to get relative path", "source", *src, "image", image, "error", err) 231 | return 232 | } 233 | output := task.ConvertExt(filepath.Join(*dst, rel)) 234 | if err := convert(task, image, output, *force); err != nil { 235 | if err == errSkip && !*quiet { 236 | log.Println("Skip", output) 237 | } 238 | return 239 | } 240 | log.Debug("Converted " + image) 241 | })) 242 | if !*quiet { 243 | pb.Done() 244 | } 245 | case srcInfo.Mode().IsRegular(): 246 | output := *dst 247 | if dstInfo.Mode().IsDir() { 248 | output = task.ConvertExt(filepath.Join(output, srcInfo.Name())) 249 | } 250 | if err := convert(task, *src, output, *force); err != nil { 251 | if err == errSkip { 252 | log.Error("Destination already exist", "destination", output) 253 | } 254 | code = 1 255 | return 256 | } 257 | default: 258 | log.Error("Unknown source mode", "mode", srcInfo.Mode()) 259 | code = 1 260 | return 261 | } 262 | log.Print("Done") 263 | } 264 | -------------------------------------------------------------------------------- /converter/misc.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "image" 7 | "io/fs" 8 | "os" 9 | "path/filepath" 10 | "regexp" 11 | "strings" 12 | "time" 13 | 14 | "github.com/sunshineplan/imgconv" 15 | "github.com/sunshineplan/tiff" 16 | "github.com/sunshineplan/utils/log" 17 | ) 18 | 19 | var ( 20 | supported = regexp.MustCompile(`(?i)\.(jpe?g|png|gif|tiff?|bmp|webp)$`) 21 | pdfImage = regexp.MustCompile(`(?i)\.pdf$`) 22 | tiffImage = regexp.MustCompile(`(?i)\.tiff?$`) 23 | ) 24 | 25 | func open(file string) (image.Image, error) { 26 | img, err := imgconv.Open(file, imgconv.AutoOrientation(*autoOrientation)) 27 | if err != nil && tiffImage.MatchString(file) { 28 | f, err := os.Open(file) 29 | if err != nil { 30 | return nil, err 31 | } 32 | defer f.Close() 33 | return tiff.Decode(f) 34 | } 35 | return img, err 36 | } 37 | 38 | func loadImages(root string, pdf bool) (imgs []string) { 39 | var message string 40 | var width int 41 | done := make(chan struct{}) 42 | ticker := time.NewTicker(time.Second) 43 | defer ticker.Stop() 44 | go func() { 45 | for { 46 | select { 47 | case <-done: 48 | return 49 | case <-ticker.C: 50 | m := message 51 | if !*quiet { 52 | fmt.Fprintf(os.Stdout, "\r%s\r%s", strings.Repeat(" ", width), m) 53 | } 54 | width = len(m) 55 | } 56 | } 57 | }() 58 | var dir string 59 | filepath.WalkDir(root, func(path string, d fs.DirEntry, _ error) error { 60 | if supported.MatchString(d.Name()) || (pdf && pdfImage.MatchString(d.Name())) { 61 | imgs = append(imgs, path) 62 | } 63 | if d.IsDir() { 64 | dir = filepath.Dir(path) 65 | } 66 | message = fmt.Sprintf("Found images: %d, Scanning directory %s", len(imgs), dir) 67 | return nil 68 | }) 69 | close(done) 70 | if !*quiet { 71 | fmt.Fprintf(os.Stdout, "\r%s\r", strings.Repeat(" ", width)) 72 | } 73 | return 74 | } 75 | 76 | var errSkip = errors.New("skip") 77 | 78 | func convert(task *imgconv.Options, image, output string, force bool) (err error) { 79 | if _, err = os.Stat(output); err == nil { 80 | if !force { 81 | return errSkip 82 | } 83 | } else if !errors.Is(err, fs.ErrNotExist) { 84 | log.Error("Failed to get FileInfo", "name", output, "error", err) 85 | return 86 | } 87 | path := filepath.Dir(output) 88 | if err = os.MkdirAll(path, 0755); err != nil { 89 | log.Error("Failed to create directory", "path", path, "error", err) 90 | return 91 | } 92 | img, err := open(image) 93 | if err != nil { 94 | log.Error("Failed to open image", "image", image, "error", err) 95 | return 96 | } 97 | f, err := os.CreateTemp(path, "*.tmp") 98 | if err != nil { 99 | log.Error("Failed to create temporary file", "path", path, "error", err) 100 | return 101 | } 102 | if err = task.Convert(f, img); err != nil { 103 | log.Error("Failed to convert image", "image", image, "error", err) 104 | return 105 | } 106 | f.Close() 107 | if err = os.Rename(f.Name(), output); err != nil { 108 | log.Error("Failed to move file", "from", f.Name(), "to", output, "error", err) 109 | } 110 | return 111 | } 112 | -------------------------------------------------------------------------------- /example_test.go: -------------------------------------------------------------------------------- 1 | package imgconv_test 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "log" 7 | 8 | "github.com/sunshineplan/imgconv" 9 | ) 10 | 11 | func Example() { 12 | // Open a test image. 13 | src, err := imgconv.Open("testdata/video-001.png") 14 | if err != nil { 15 | log.Fatalf("failed to open image: %v", err) 16 | } 17 | 18 | // Resize the image to width = 200px preserving the aspect ratio. 19 | mark := imgconv.Resize(src, &imgconv.ResizeOption{Width: 200}) 20 | 21 | // Add random watermark set opacity = 128. 22 | dst := imgconv.Watermark(src, &imgconv.WatermarkOption{Mark: mark, Opacity: 128, Random: true}) 23 | 24 | // Write the resulting image as TIFF. 25 | if err := imgconv.Write(io.Discard, dst, &imgconv.FormatOption{Format: imgconv.TIFF}); err != nil { 26 | log.Fatalf("failed to write image: %v", err) 27 | } 28 | 29 | // Split the image into 3 parts horizontally. 30 | imgs, err := imgconv.Split(src, 3, imgconv.SplitHorizontalMode) 31 | if err != nil { 32 | log.Fatalf("failed to split image: %v", err) 33 | } 34 | fmt.Print(len(imgs)) 35 | // output:3 36 | } 37 | -------------------------------------------------------------------------------- /format.go: -------------------------------------------------------------------------------- 1 | package imgconv 2 | 3 | import ( 4 | "encoding" 5 | "fmt" 6 | "image" 7 | "image/color" 8 | "image/draw" 9 | "image/gif" 10 | "image/jpeg" 11 | "image/png" 12 | "io" 13 | "slices" 14 | "strings" 15 | 16 | "github.com/HugoSmits86/nativewebp" 17 | "github.com/sunshineplan/pdf" 18 | "golang.org/x/image/bmp" 19 | "golang.org/x/image/tiff" 20 | ) 21 | 22 | var ( 23 | _ encoding.TextUnmarshaler = new(Format) 24 | _ encoding.TextMarshaler = Format(0) 25 | ) 26 | 27 | // Format is an image file format. 28 | type Format int 29 | 30 | // Image file formats. 31 | const ( 32 | JPEG Format = iota 33 | PNG 34 | GIF 35 | TIFF 36 | BMP 37 | PDF 38 | WEBP 39 | ) 40 | 41 | var formatExts = [][]string{ 42 | {"jpg", "jpeg"}, 43 | {"png"}, 44 | {"gif"}, 45 | {"tif", "tiff"}, 46 | {"bmp"}, 47 | {"pdf"}, 48 | {"webp"}, 49 | } 50 | 51 | func (f Format) String() (format string) { 52 | defer func() { 53 | if err := recover(); err != nil { 54 | format = "unknown" 55 | } 56 | }() 57 | return formatExts[f][0] 58 | } 59 | 60 | // FormatFromExtension parses image format from filename extension: 61 | // "jpg" (or "jpeg"), "png", "gif", "tif" (or "tiff"), "bmp", "pdf" and "webp" are supported. 62 | func FormatFromExtension(ext string) (Format, error) { 63 | ext = strings.ToLower(ext) 64 | for index, exts := range formatExts { 65 | if slices.Contains(exts, ext) { 66 | return Format(index), nil 67 | } 68 | } 69 | 70 | return -1, image.ErrFormat 71 | } 72 | 73 | func (f *Format) UnmarshalText(text []byte) error { 74 | format, err := FormatFromExtension(string(text)) 75 | if err != nil { 76 | return err 77 | } 78 | *f = format 79 | return nil 80 | } 81 | 82 | func (f Format) MarshalText() ([]byte, error) { 83 | return []byte(f.String()), nil 84 | } 85 | 86 | var ( 87 | _ encoding.TextUnmarshaler = new(TIFFCompression) 88 | _ encoding.TextMarshaler = TIFFCompression(0) 89 | ) 90 | 91 | // TIFFCompression describes the type of compression used in Options. 92 | type TIFFCompression int 93 | 94 | // Constants for supported TIFF compression types. 95 | const ( 96 | TIFFUncompressed TIFFCompression = iota 97 | TIFFDeflate 98 | ) 99 | 100 | var tiffCompression = []string{ 101 | "none", 102 | "deflate", 103 | } 104 | 105 | func (c TIFFCompression) value() tiff.CompressionType { 106 | switch c { 107 | case TIFFDeflate: 108 | return tiff.Deflate 109 | } 110 | return tiff.Uncompressed 111 | } 112 | 113 | func (c *TIFFCompression) UnmarshalText(text []byte) error { 114 | t := strings.ToLower(string(text)) 115 | for index, tt := range tiffCompression { 116 | if t == tt { 117 | *c = TIFFCompression(index) 118 | return nil 119 | } 120 | } 121 | return fmt.Errorf("tiff: unsupported compression: %s", t) 122 | } 123 | 124 | func (c TIFFCompression) MarshalText() (b []byte, err error) { 125 | defer func() { 126 | if err := recover(); err != nil { 127 | b = []byte("unknown") 128 | } 129 | }() 130 | ct := tiffCompression[c] 131 | return []byte(ct), nil 132 | } 133 | 134 | // FormatOption is format option 135 | type FormatOption struct { 136 | Format Format 137 | EncodeOption []EncodeOption 138 | } 139 | 140 | type encodeConfig struct { 141 | Quality int 142 | gifNumColors int 143 | gifQuantizer draw.Quantizer 144 | gifDrawer draw.Drawer 145 | pngCompressionLevel png.CompressionLevel 146 | tiffCompressionType TIFFCompression 147 | webpUseExtendedFormat bool 148 | background color.Color 149 | } 150 | 151 | var defaultEncodeConfig = encodeConfig{ 152 | Quality: 75, 153 | gifNumColors: 256, 154 | gifQuantizer: nil, 155 | gifDrawer: nil, 156 | pngCompressionLevel: png.DefaultCompression, 157 | tiffCompressionType: TIFFDeflate, 158 | } 159 | 160 | // EncodeOption sets an optional parameter for the Encode and Save functions. 161 | // https://github.com/disintegration/imaging 162 | type EncodeOption func(*encodeConfig) 163 | 164 | // Quality returns an EncodeOption that sets the output JPEG or PDF quality. 165 | // Quality ranges from 1 to 100 inclusive, higher is better. 166 | func Quality(quality int) EncodeOption { 167 | return func(c *encodeConfig) { 168 | c.Quality = quality 169 | } 170 | } 171 | 172 | // GIFNumColors returns an EncodeOption that sets the maximum number of colors 173 | // used in the GIF-encoded image. It ranges from 1 to 256. Default is 256. 174 | func GIFNumColors(numColors int) EncodeOption { 175 | return func(c *encodeConfig) { 176 | c.gifNumColors = numColors 177 | } 178 | } 179 | 180 | // GIFQuantizer returns an EncodeOption that sets the quantizer that is used to produce 181 | // a palette of the GIF-encoded image. 182 | func GIFQuantizer(quantizer draw.Quantizer) EncodeOption { 183 | return func(c *encodeConfig) { 184 | c.gifQuantizer = quantizer 185 | } 186 | } 187 | 188 | // GIFDrawer returns an EncodeOption that sets the drawer that is used to convert 189 | // the source image to the desired palette of the GIF-encoded image. 190 | func GIFDrawer(drawer draw.Drawer) EncodeOption { 191 | return func(c *encodeConfig) { 192 | c.gifDrawer = drawer 193 | } 194 | } 195 | 196 | // PNGCompressionLevel returns an EncodeOption that sets the compression level 197 | // of the PNG-encoded image. Default is png.DefaultCompression. 198 | func PNGCompressionLevel(level png.CompressionLevel) EncodeOption { 199 | return func(c *encodeConfig) { 200 | c.pngCompressionLevel = level 201 | } 202 | } 203 | 204 | // TIFFCompressionType returns an EncodeOption that sets the compression type 205 | // of the TIFF-encoded image. Default is tiff.Deflate. 206 | func TIFFCompressionType(compressionType TIFFCompression) EncodeOption { 207 | return func(c *encodeConfig) { 208 | c.tiffCompressionType = compressionType 209 | } 210 | } 211 | 212 | // WEBPUseExtendedFormat returns EncodeOption that determines whether to use extended format 213 | // of the WEBP-encoded image. Default is false. 214 | func WEBPUseExtendedFormat(b bool) EncodeOption { 215 | return func(c *encodeConfig) { 216 | c.webpUseExtendedFormat = b 217 | } 218 | } 219 | 220 | // BackgroundColor returns an EncodeOption that sets the background color. 221 | func BackgroundColor(color color.Color) EncodeOption { 222 | return func(c *encodeConfig) { 223 | c.background = color 224 | } 225 | } 226 | 227 | // Encode writes the image img to w in the specified format (JPEG, PNG, GIF, TIFF, BMP, PDF or WEBP). 228 | func (f *FormatOption) Encode(w io.Writer, img image.Image) error { 229 | cfg := defaultEncodeConfig 230 | for _, option := range f.EncodeOption { 231 | option(&cfg) 232 | } 233 | 234 | if cfg.background != nil { 235 | i := image.NewNRGBA(img.Bounds()) 236 | draw.Draw(i, i.Bounds(), &image.Uniform{cfg.background}, img.Bounds().Min, draw.Src) 237 | draw.Draw(i, i.Bounds(), img, img.Bounds().Min, draw.Over) 238 | img = i 239 | } 240 | 241 | switch f.Format { 242 | case JPEG: 243 | if nrgba, ok := img.(*image.NRGBA); ok && nrgba.Opaque() { 244 | rgba := &image.RGBA{ 245 | Pix: nrgba.Pix, 246 | Stride: nrgba.Stride, 247 | Rect: nrgba.Rect, 248 | } 249 | return jpeg.Encode(w, rgba, &jpeg.Options{Quality: cfg.Quality}) 250 | } 251 | return jpeg.Encode(w, img, &jpeg.Options{Quality: cfg.Quality}) 252 | 253 | case PNG: 254 | encoder := png.Encoder{CompressionLevel: cfg.pngCompressionLevel} 255 | return encoder.Encode(w, img) 256 | 257 | case GIF: 258 | return gif.Encode(w, img, &gif.Options{ 259 | NumColors: cfg.gifNumColors, 260 | Quantizer: cfg.gifQuantizer, 261 | Drawer: cfg.gifDrawer, 262 | }) 263 | 264 | case TIFF: 265 | return tiff.Encode(w, img, &tiff.Options{Compression: cfg.tiffCompressionType.value(), Predictor: true}) 266 | 267 | case BMP: 268 | return bmp.Encode(w, img) 269 | 270 | case PDF: 271 | return pdf.Encode(w, []image.Image{img}, &pdf.Options{Quality: cfg.Quality}) 272 | 273 | case WEBP: 274 | return nativewebp.Encode(w, img, &nativewebp.Options{UseExtendedFormat: cfg.webpUseExtendedFormat}) 275 | } 276 | 277 | return image.ErrFormat 278 | } 279 | -------------------------------------------------------------------------------- /format_test.go: -------------------------------------------------------------------------------- 1 | package imgconv 2 | 3 | import ( 4 | "bytes" 5 | "flag" 6 | "image" 7 | "image/draw" 8 | "image/png" 9 | "io" 10 | "testing" 11 | ) 12 | 13 | func TestFormatFromExtension(t *testing.T) { 14 | if _, err := FormatFromExtension("Jpg"); err != nil { 15 | t.Fatal("jpg format want no error") 16 | } 17 | if _, err := FormatFromExtension("TIFF"); err != nil { 18 | t.Fatal("tiff format want no error") 19 | } 20 | if _, err := FormatFromExtension("txt"); err == nil { 21 | t.Fatal("txt format want error") 22 | } 23 | } 24 | 25 | func TestTextVar(t *testing.T) { 26 | testCase1 := []struct { 27 | argument string 28 | format Format 29 | }{ 30 | {"Jpg", JPEG}, 31 | {"TIFF", TIFF}, 32 | {"txt", Format(-1)}, 33 | } 34 | for _, tc := range testCase1 { 35 | f := flag.NewFlagSet("test", flag.ContinueOnError) 36 | f.SetOutput(io.Discard) 37 | var format Format 38 | f.TextVar(&format, "f", Format(-1), "") 39 | f.Parse(append([]string{"-f"}, tc.argument)) 40 | if format != tc.format { 41 | t.Errorf("expected %s format; got %s", tc.format, format) 42 | } 43 | } 44 | testCase2 := []struct { 45 | argument string 46 | compression TIFFCompression 47 | }{ 48 | {"none", TIFFUncompressed}, 49 | {"Deflate", TIFFDeflate}, 50 | {"lzw", TIFFCompression(-1)}, 51 | } 52 | for _, tc := range testCase2 { 53 | f := flag.NewFlagSet("test", flag.ContinueOnError) 54 | f.SetOutput(io.Discard) 55 | var compression TIFFCompression 56 | f.TextVar(&compression, "c", TIFFCompression(-1), "") 57 | f.Parse(append([]string{"-c"}, tc.argument)) 58 | if compression != tc.compression { 59 | t.Errorf("expected %d compression; got %d", tc.compression, compression) 60 | } 61 | } 62 | } 63 | 64 | func TestEncode(t *testing.T) { 65 | testCase := []FormatOption{ 66 | {Format: JPEG, EncodeOption: []EncodeOption{Quality(75)}}, 67 | {Format: PNG, EncodeOption: []EncodeOption{PNGCompressionLevel(png.DefaultCompression)}}, 68 | {Format: GIF, EncodeOption: []EncodeOption{GIFNumColors(256), GIFDrawer(draw.FloydSteinberg), GIFQuantizer(nil)}}, 69 | {Format: TIFF, EncodeOption: []EncodeOption{TIFFCompressionType(TIFFDeflate)}}, 70 | {Format: BMP}, 71 | {Format: PDF, EncodeOption: []EncodeOption{Quality(75)}}, 72 | } 73 | 74 | // Read the image. 75 | m0, err := Open("testdata/video-001.png") 76 | if err != nil { 77 | t.Fatal(err) 78 | } 79 | 80 | for _, tc := range testCase { 81 | // Encode the image. 82 | var buf bytes.Buffer 83 | fo := &FormatOption{tc.Format, tc.EncodeOption} 84 | if err := fo.Encode(&buf, m0); err != nil { 85 | t.Fatal(formatExts[fo.Format], err) 86 | } 87 | 88 | // Decode the image. 89 | m1, err := Decode(&buf) 90 | if err != nil { 91 | t.Fatal(formatExts[fo.Format], err) 92 | } 93 | 94 | if m0.Bounds() != m1.Bounds() { 95 | t.Fatalf("bounds differ: %v and %v", m0.Bounds(), m1.Bounds()) 96 | } 97 | } 98 | 99 | if err := (&FormatOption{}).Encode( 100 | io.Discard, 101 | &image.NRGBA{ 102 | Rect: image.Rect(0, 0, 1, 1), 103 | Stride: 1 * 4, 104 | Pix: []uint8{0xff, 0xff, 0xff, 0xff}, 105 | }, 106 | ); err != nil { 107 | t.Fatal("encode image error") 108 | } 109 | 110 | if err := (&FormatOption{Format: -1}).Encode(io.Discard, m0); err == nil { 111 | t.Fatal("encode unsupported format expect an error") 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/sunshineplan/imgconv 2 | 3 | go 1.24 4 | 5 | require ( 6 | github.com/HugoSmits86/nativewebp v1.2.0 7 | github.com/sunshineplan/pdf v1.0.8 8 | golang.org/x/image v0.27.0 9 | ) 10 | 11 | require ( 12 | github.com/hhrutter/lzw v1.0.0 // indirect 13 | github.com/hhrutter/tiff v1.0.1 // indirect 14 | github.com/mattn/go-runewidth v0.0.16 // indirect 15 | github.com/pdfcpu/pdfcpu v0.9.1 // indirect 16 | github.com/pkg/errors v0.9.1 // indirect 17 | github.com/rivo/uniseg v0.4.7 // indirect 18 | golang.org/x/text v0.25.0 // indirect 19 | gopkg.in/yaml.v2 v2.4.0 // indirect 20 | ) 21 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/HugoSmits86/nativewebp v1.2.0 h1:XJtXeTg7FsOi9VB1elQYZy3n6VjYLqofSr3gGRLUOp4= 2 | github.com/HugoSmits86/nativewebp v1.2.0/go.mod h1:YNQuWenlVmSUUASVNhTDwf4d7FwYQGbGhklC8p72Vr8= 3 | github.com/hhrutter/lzw v1.0.0 h1:laL89Llp86W3rRs83LvKbwYRx6INE8gDn0XNb1oXtm0= 4 | github.com/hhrutter/lzw v1.0.0/go.mod h1:2HC6DJSn/n6iAZfgM3Pg+cP1KxeWc3ezG8bBqW5+WEo= 5 | github.com/hhrutter/tiff v1.0.1 h1:MIus8caHU5U6823gx7C6jrfoEvfSTGtEFRiM8/LOzC0= 6 | github.com/hhrutter/tiff v1.0.1/go.mod h1:zU/dNgDm0cMIa8y8YwcYBeuEEveI4B0owqHyiPpJPHc= 7 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 8 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 9 | github.com/pdfcpu/pdfcpu v0.9.1 h1:q8/KlBdHjkE7ZJU4ofhKG5Rjf7M6L324CVM6BMDySao= 10 | github.com/pdfcpu/pdfcpu v0.9.1/go.mod h1:fVfOloBzs2+W2VJCCbq60XIxc3yJHAZ0Gahv1oO0gyI= 11 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 12 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 13 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 14 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 15 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 16 | github.com/sunshineplan/pdf v1.0.8 h1:5/HWBjgPX/3WGe+GHkC0KxUS43pd/jqfZ0F+u9pDiG8= 17 | github.com/sunshineplan/pdf v1.0.8/go.mod h1:pvucuW/bqRdicCSIm3RNjnyYXkNFXVIvJ8ePLCeK2s8= 18 | golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w= 19 | golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g= 20 | golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= 21 | golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= 22 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 23 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 24 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 25 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 26 | -------------------------------------------------------------------------------- /gray.go: -------------------------------------------------------------------------------- 1 | package imgconv 2 | 3 | import "image" 4 | 5 | func ToGray(img image.Image) image.Image { 6 | bounds := img.Bounds() 7 | gray := image.NewGray(bounds) 8 | for y := bounds.Min.Y; y < bounds.Max.Y; y++ { 9 | for x := bounds.Min.X; x < bounds.Max.X; x++ { 10 | gray.Set(x, y, img.At(x, y)) 11 | } 12 | } 13 | 14 | return gray 15 | } 16 | -------------------------------------------------------------------------------- /gray_test.go: -------------------------------------------------------------------------------- 1 | package imgconv 2 | 3 | import ( 4 | "image" 5 | "testing" 6 | ) 7 | 8 | func TestGray(t *testing.T) { 9 | sample, err := Open("testdata/video-001.png") 10 | if err != nil { 11 | t.Fatal(err) 12 | } 13 | 14 | img := ToGray(sample) 15 | if img.Bounds().Size() != sample.Bounds().Size() { 16 | t.Fatalf("bounds differ: %v and %v", img.Bounds().Size(), sample.Bounds().Size()) 17 | } 18 | if _, ok := img.(*image.Gray); !ok { 19 | t.Fatal("img is not gray") 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /imaging.go: -------------------------------------------------------------------------------- 1 | // github.com/disintegration/imaging 2 | package imgconv 3 | 4 | import ( 5 | "encoding/binary" 6 | "image" 7 | "image/color" 8 | "io" 9 | "math" 10 | "runtime" 11 | "sync" 12 | "sync/atomic" 13 | ) 14 | 15 | // 16 | // io.go 17 | // 18 | 19 | // DecodeOption sets an optional parameter for the Decode and Open functions. 20 | type decodeOption func(*decodeConfig) 21 | 22 | // AutoOrientation returns a DecodeOption that sets the auto-orientation mode. 23 | // If auto-orientation is enabled, the image will be transformed after decoding 24 | // according to the EXIF orientation tag (if present). By default it's disabled. 25 | func autoOrientation(enabled bool) decodeOption { 26 | return func(c *decodeConfig) { 27 | c.autoOrientation = enabled 28 | } 29 | } 30 | 31 | // Decode reads an image from r. 32 | func decode(r io.Reader, opts ...decodeOption) (image.Image, error) { 33 | cfg := defaultDecodeConfig 34 | for _, option := range opts { 35 | option(&cfg) 36 | } 37 | 38 | if !cfg.autoOrientation { 39 | img, _, err := image.Decode(r) 40 | return img, err 41 | } 42 | 43 | var orient orientation 44 | pr, pw := io.Pipe() 45 | r = io.TeeReader(r, pw) 46 | done := make(chan struct{}) 47 | go func() { 48 | defer close(done) 49 | orient = readOrientation(pr) 50 | io.Copy(io.Discard, pr) 51 | }() 52 | 53 | img, _, err := image.Decode(r) 54 | pw.Close() 55 | <-done 56 | if err != nil { 57 | return nil, err 58 | } 59 | 60 | return fixOrientation(img, orient), nil 61 | } 62 | 63 | // 64 | // resize.go 65 | // 66 | 67 | type indexWeight struct { 68 | index int 69 | weight float64 70 | } 71 | 72 | func precomputeWeights(dstSize, srcSize int, filter resampleFilter) [][]indexWeight { 73 | du := float64(srcSize) / float64(dstSize) 74 | scale := du 75 | if scale < 1.0 { 76 | scale = 1.0 77 | } 78 | ru := math.Ceil(scale * filter.Support) 79 | 80 | out := make([][]indexWeight, dstSize) 81 | tmp := make([]indexWeight, 0, dstSize*int(ru+2)*2) 82 | 83 | for v := 0; v < dstSize; v++ { 84 | fu := (float64(v)+0.5)*du - 0.5 85 | 86 | begin := int(math.Ceil(fu - ru)) 87 | if begin < 0 { 88 | begin = 0 89 | } 90 | end := int(math.Floor(fu + ru)) 91 | if end > srcSize-1 { 92 | end = srcSize - 1 93 | } 94 | 95 | var sum float64 96 | for u := begin; u <= end; u++ { 97 | w := filter.Kernel((float64(u) - fu) / scale) 98 | if w != 0 { 99 | sum += w 100 | tmp = append(tmp, indexWeight{index: u, weight: w}) 101 | } 102 | } 103 | if sum != 0 { 104 | for i := range tmp { 105 | tmp[i].weight /= sum 106 | } 107 | } 108 | 109 | out[v] = tmp 110 | tmp = tmp[len(tmp):] 111 | } 112 | 113 | return out 114 | } 115 | 116 | // Resize resizes the image to the specified width and height using the specified resampling 117 | // filter and returns the transformed image. If one of width or height is 0, the image aspect 118 | // ratio is preserved. 119 | // 120 | // Example: 121 | // 122 | // dstImage := imaging.Resize(srcImage, 800, 600, imaging.Lanczos) 123 | func resize(img image.Image, width, height int, filter resampleFilter) *image.NRGBA { 124 | dstW, dstH := width, height 125 | if dstW < 0 || dstH < 0 { 126 | return &image.NRGBA{} 127 | } 128 | if dstW == 0 && dstH == 0 { 129 | return &image.NRGBA{} 130 | } 131 | 132 | srcW := img.Bounds().Dx() 133 | srcH := img.Bounds().Dy() 134 | if srcW <= 0 || srcH <= 0 { 135 | return &image.NRGBA{} 136 | } 137 | 138 | // If new width or height is 0 then preserve aspect ratio, minimum 1px. 139 | if dstW == 0 { 140 | tmpW := float64(dstH) * float64(srcW) / float64(srcH) 141 | dstW = int(math.Max(1.0, math.Floor(tmpW+0.5))) 142 | } 143 | if dstH == 0 { 144 | tmpH := float64(dstW) * float64(srcH) / float64(srcW) 145 | dstH = int(math.Max(1.0, math.Floor(tmpH+0.5))) 146 | } 147 | 148 | if srcW == dstW && srcH == dstH { 149 | return clone(img) 150 | } 151 | 152 | if srcW != dstW && srcH != dstH { 153 | return resizeVertical(resizeHorizontal(img, dstW, filter), dstH, filter) 154 | } 155 | if srcW != dstW { 156 | return resizeHorizontal(img, dstW, filter) 157 | } 158 | return resizeVertical(img, dstH, filter) 159 | 160 | } 161 | 162 | func resizeHorizontal(img image.Image, width int, filter resampleFilter) *image.NRGBA { 163 | src := newScanner(img) 164 | dst := image.NewNRGBA(image.Rect(0, 0, width, src.h)) 165 | weights := precomputeWeights(width, src.w, filter) 166 | parallel(0, src.h, func(ys <-chan int) { 167 | scanLine := make([]uint8, src.w*4) 168 | for y := range ys { 169 | src.scan(0, y, src.w, y+1, scanLine) 170 | j0 := y * dst.Stride 171 | for x := range weights { 172 | var r, g, b, a float64 173 | for _, w := range weights[x] { 174 | i := w.index * 4 175 | s := scanLine[i : i+4 : i+4] 176 | aw := float64(s[3]) * w.weight 177 | r += float64(s[0]) * aw 178 | g += float64(s[1]) * aw 179 | b += float64(s[2]) * aw 180 | a += aw 181 | } 182 | if a != 0 { 183 | aInv := 1 / a 184 | j := j0 + x*4 185 | d := dst.Pix[j : j+4 : j+4] 186 | d[0] = clamp(r * aInv) 187 | d[1] = clamp(g * aInv) 188 | d[2] = clamp(b * aInv) 189 | d[3] = clamp(a) 190 | } 191 | } 192 | } 193 | }) 194 | return dst 195 | } 196 | 197 | func resizeVertical(img image.Image, height int, filter resampleFilter) *image.NRGBA { 198 | src := newScanner(img) 199 | dst := image.NewNRGBA(image.Rect(0, 0, src.w, height)) 200 | weights := precomputeWeights(height, src.h, filter) 201 | parallel(0, src.w, func(xs <-chan int) { 202 | scanLine := make([]uint8, src.h*4) 203 | for x := range xs { 204 | src.scan(x, 0, x+1, src.h, scanLine) 205 | for y := range weights { 206 | var r, g, b, a float64 207 | for _, w := range weights[y] { 208 | i := w.index * 4 209 | s := scanLine[i : i+4 : i+4] 210 | aw := float64(s[3]) * w.weight 211 | r += float64(s[0]) * aw 212 | g += float64(s[1]) * aw 213 | b += float64(s[2]) * aw 214 | a += aw 215 | } 216 | if a != 0 { 217 | aInv := 1 / a 218 | j := y*dst.Stride + x*4 219 | d := dst.Pix[j : j+4 : j+4] 220 | d[0] = clamp(r * aInv) 221 | d[1] = clamp(g * aInv) 222 | d[2] = clamp(b * aInv) 223 | d[3] = clamp(a) 224 | } 225 | } 226 | } 227 | }) 228 | return dst 229 | } 230 | 231 | type resampleFilter struct { 232 | Support float64 233 | Kernel func(float64) float64 234 | } 235 | 236 | func sinc(x float64) float64 { 237 | if x == 0 { 238 | return 1 239 | } 240 | return math.Sin(math.Pi*x) / (math.Pi * x) 241 | } 242 | 243 | var lanczos = resampleFilter{ 244 | Support: 3.0, 245 | Kernel: func(x float64) float64 { 246 | x = math.Abs(x) 247 | if x < 3.0 { 248 | return sinc(x) * sinc(x/3.0) 249 | } 250 | return 0 251 | }, 252 | } 253 | 254 | // 255 | // scanner.go 256 | // 257 | 258 | type scanner struct { 259 | image image.Image 260 | w, h int 261 | palette []color.NRGBA 262 | } 263 | 264 | func newScanner(img image.Image) *scanner { 265 | s := &scanner{ 266 | image: img, 267 | w: img.Bounds().Dx(), 268 | h: img.Bounds().Dy(), 269 | } 270 | if img, ok := img.(*image.Paletted); ok { 271 | s.palette = make([]color.NRGBA, len(img.Palette)) 272 | for i := 0; i < len(img.Palette); i++ { 273 | s.palette[i] = color.NRGBAModel.Convert(img.Palette[i]).(color.NRGBA) 274 | } 275 | } 276 | return s 277 | } 278 | 279 | // scan scans the given rectangular region of the image into dst. 280 | func (s *scanner) scan(x1, y1, x2, y2 int, dst []uint8) { 281 | switch img := s.image.(type) { 282 | case *image.NRGBA: 283 | size := (x2 - x1) * 4 284 | j := 0 285 | i := y1*img.Stride + x1*4 286 | if size == 4 { 287 | for y := y1; y < y2; y++ { 288 | d := dst[j : j+4 : j+4] 289 | s := img.Pix[i : i+4 : i+4] 290 | d[0] = s[0] 291 | d[1] = s[1] 292 | d[2] = s[2] 293 | d[3] = s[3] 294 | j += size 295 | i += img.Stride 296 | } 297 | } else { 298 | for y := y1; y < y2; y++ { 299 | copy(dst[j:j+size], img.Pix[i:i+size]) 300 | j += size 301 | i += img.Stride 302 | } 303 | } 304 | 305 | case *image.NRGBA64: 306 | j := 0 307 | for y := y1; y < y2; y++ { 308 | i := y*img.Stride + x1*8 309 | for x := x1; x < x2; x++ { 310 | s := img.Pix[i : i+8 : i+8] 311 | d := dst[j : j+4 : j+4] 312 | d[0] = s[0] 313 | d[1] = s[2] 314 | d[2] = s[4] 315 | d[3] = s[6] 316 | j += 4 317 | i += 8 318 | } 319 | } 320 | 321 | case *image.RGBA: 322 | j := 0 323 | for y := y1; y < y2; y++ { 324 | i := y*img.Stride + x1*4 325 | for x := x1; x < x2; x++ { 326 | d := dst[j : j+4 : j+4] 327 | a := img.Pix[i+3] 328 | switch a { 329 | case 0: 330 | d[0] = 0 331 | d[1] = 0 332 | d[2] = 0 333 | d[3] = a 334 | case 0xff: 335 | s := img.Pix[i : i+4 : i+4] 336 | d[0] = s[0] 337 | d[1] = s[1] 338 | d[2] = s[2] 339 | d[3] = a 340 | default: 341 | s := img.Pix[i : i+4 : i+4] 342 | r16 := uint16(s[0]) 343 | g16 := uint16(s[1]) 344 | b16 := uint16(s[2]) 345 | a16 := uint16(a) 346 | d[0] = uint8(r16 * 0xff / a16) 347 | d[1] = uint8(g16 * 0xff / a16) 348 | d[2] = uint8(b16 * 0xff / a16) 349 | d[3] = a 350 | } 351 | j += 4 352 | i += 4 353 | } 354 | } 355 | 356 | case *image.RGBA64: 357 | j := 0 358 | for y := y1; y < y2; y++ { 359 | i := y*img.Stride + x1*8 360 | for x := x1; x < x2; x++ { 361 | s := img.Pix[i : i+8 : i+8] 362 | d := dst[j : j+4 : j+4] 363 | a := s[6] 364 | switch a { 365 | case 0: 366 | d[0] = 0 367 | d[1] = 0 368 | d[2] = 0 369 | case 0xff: 370 | d[0] = s[0] 371 | d[1] = s[2] 372 | d[2] = s[4] 373 | default: 374 | r32 := uint32(s[0])<<8 | uint32(s[1]) 375 | g32 := uint32(s[2])<<8 | uint32(s[3]) 376 | b32 := uint32(s[4])<<8 | uint32(s[5]) 377 | a32 := uint32(s[6])<<8 | uint32(s[7]) 378 | d[0] = uint8((r32 * 0xffff / a32) >> 8) 379 | d[1] = uint8((g32 * 0xffff / a32) >> 8) 380 | d[2] = uint8((b32 * 0xffff / a32) >> 8) 381 | } 382 | d[3] = a 383 | j += 4 384 | i += 8 385 | } 386 | } 387 | 388 | case *image.Gray: 389 | j := 0 390 | for y := y1; y < y2; y++ { 391 | i := y*img.Stride + x1 392 | for x := x1; x < x2; x++ { 393 | c := img.Pix[i] 394 | d := dst[j : j+4 : j+4] 395 | d[0] = c 396 | d[1] = c 397 | d[2] = c 398 | d[3] = 0xff 399 | j += 4 400 | i++ 401 | } 402 | } 403 | 404 | case *image.Gray16: 405 | j := 0 406 | for y := y1; y < y2; y++ { 407 | i := y*img.Stride + x1*2 408 | for x := x1; x < x2; x++ { 409 | c := img.Pix[i] 410 | d := dst[j : j+4 : j+4] 411 | d[0] = c 412 | d[1] = c 413 | d[2] = c 414 | d[3] = 0xff 415 | j += 4 416 | i += 2 417 | } 418 | } 419 | 420 | case *image.YCbCr: 421 | j := 0 422 | x1 += img.Rect.Min.X 423 | x2 += img.Rect.Min.X 424 | y1 += img.Rect.Min.Y 425 | y2 += img.Rect.Min.Y 426 | 427 | hy := img.Rect.Min.Y / 2 428 | hx := img.Rect.Min.X / 2 429 | for y := y1; y < y2; y++ { 430 | iy := (y-img.Rect.Min.Y)*img.YStride + (x1 - img.Rect.Min.X) 431 | 432 | var yBase int 433 | switch img.SubsampleRatio { 434 | case image.YCbCrSubsampleRatio444, image.YCbCrSubsampleRatio422: 435 | yBase = (y - img.Rect.Min.Y) * img.CStride 436 | case image.YCbCrSubsampleRatio420, image.YCbCrSubsampleRatio440: 437 | yBase = (y/2 - hy) * img.CStride 438 | } 439 | 440 | for x := x1; x < x2; x++ { 441 | var ic int 442 | switch img.SubsampleRatio { 443 | case image.YCbCrSubsampleRatio444, image.YCbCrSubsampleRatio440: 444 | ic = yBase + (x - img.Rect.Min.X) 445 | case image.YCbCrSubsampleRatio422, image.YCbCrSubsampleRatio420: 446 | ic = yBase + (x/2 - hx) 447 | default: 448 | ic = img.COffset(x, y) 449 | } 450 | 451 | yy1 := int32(img.Y[iy]) * 0x10101 452 | cb1 := int32(img.Cb[ic]) - 128 453 | cr1 := int32(img.Cr[ic]) - 128 454 | 455 | r := yy1 + 91881*cr1 456 | if uint32(r)&0xff000000 == 0 { 457 | r >>= 16 458 | } else { 459 | r = ^(r >> 31) 460 | } 461 | 462 | g := yy1 - 22554*cb1 - 46802*cr1 463 | if uint32(g)&0xff000000 == 0 { 464 | g >>= 16 465 | } else { 466 | g = ^(g >> 31) 467 | } 468 | 469 | b := yy1 + 116130*cb1 470 | if uint32(b)&0xff000000 == 0 { 471 | b >>= 16 472 | } else { 473 | b = ^(b >> 31) 474 | } 475 | 476 | d := dst[j : j+4 : j+4] 477 | d[0] = uint8(r) 478 | d[1] = uint8(g) 479 | d[2] = uint8(b) 480 | d[3] = 0xff 481 | 482 | iy++ 483 | j += 4 484 | } 485 | } 486 | 487 | case *image.Paletted: 488 | j := 0 489 | for y := y1; y < y2; y++ { 490 | i := y*img.Stride + x1 491 | for x := x1; x < x2; x++ { 492 | c := s.palette[img.Pix[i]] 493 | d := dst[j : j+4 : j+4] 494 | d[0] = c.R 495 | d[1] = c.G 496 | d[2] = c.B 497 | d[3] = c.A 498 | j += 4 499 | i++ 500 | } 501 | } 502 | 503 | default: 504 | j := 0 505 | b := s.image.Bounds() 506 | x1 += b.Min.X 507 | x2 += b.Min.X 508 | y1 += b.Min.Y 509 | y2 += b.Min.Y 510 | for y := y1; y < y2; y++ { 511 | for x := x1; x < x2; x++ { 512 | r16, g16, b16, a16 := s.image.At(x, y).RGBA() 513 | d := dst[j : j+4 : j+4] 514 | switch a16 { 515 | case 0xffff: 516 | d[0] = uint8(r16 >> 8) 517 | d[1] = uint8(g16 >> 8) 518 | d[2] = uint8(b16 >> 8) 519 | d[3] = 0xff 520 | case 0: 521 | d[0] = 0 522 | d[1] = 0 523 | d[2] = 0 524 | d[3] = 0 525 | default: 526 | d[0] = uint8(((r16 * 0xffff) / a16) >> 8) 527 | d[1] = uint8(((g16 * 0xffff) / a16) >> 8) 528 | d[2] = uint8(((b16 * 0xffff) / a16) >> 8) 529 | d[3] = uint8(a16 >> 8) 530 | } 531 | j += 4 532 | } 533 | } 534 | } 535 | } 536 | 537 | // 538 | // tools.go 539 | // 540 | 541 | // FlipH flips the image horizontally (from left to right) and returns the transformed image. 542 | func flipH(img image.Image) *image.NRGBA { 543 | src := newScanner(img) 544 | dstW := src.w 545 | dstH := src.h 546 | rowSize := dstW * 4 547 | dst := image.NewNRGBA(image.Rect(0, 0, dstW, dstH)) 548 | parallel(0, dstH, func(ys <-chan int) { 549 | for dstY := range ys { 550 | i := dstY * dst.Stride 551 | srcY := dstY 552 | src.scan(0, srcY, src.w, srcY+1, dst.Pix[i:i+rowSize]) 553 | reverse(dst.Pix[i : i+rowSize]) 554 | } 555 | }) 556 | return dst 557 | } 558 | 559 | // FlipV flips the image vertically (from top to bottom) and returns the transformed image. 560 | func flipV(img image.Image) *image.NRGBA { 561 | src := newScanner(img) 562 | dstW := src.w 563 | dstH := src.h 564 | rowSize := dstW * 4 565 | dst := image.NewNRGBA(image.Rect(0, 0, dstW, dstH)) 566 | parallel(0, dstH, func(ys <-chan int) { 567 | for dstY := range ys { 568 | i := dstY * dst.Stride 569 | srcY := dstH - dstY - 1 570 | src.scan(0, srcY, src.w, srcY+1, dst.Pix[i:i+rowSize]) 571 | } 572 | }) 573 | return dst 574 | } 575 | 576 | // Transpose flips the image horizontally and rotates 90 degrees counter-clockwise. 577 | func transpose(img image.Image) *image.NRGBA { 578 | src := newScanner(img) 579 | dstW := src.h 580 | dstH := src.w 581 | rowSize := dstW * 4 582 | dst := image.NewNRGBA(image.Rect(0, 0, dstW, dstH)) 583 | parallel(0, dstH, func(ys <-chan int) { 584 | for dstY := range ys { 585 | i := dstY * dst.Stride 586 | srcX := dstY 587 | src.scan(srcX, 0, srcX+1, src.h, dst.Pix[i:i+rowSize]) 588 | } 589 | }) 590 | return dst 591 | } 592 | 593 | // Transverse flips the image vertically and rotates 90 degrees counter-clockwise. 594 | func transverse(img image.Image) *image.NRGBA { 595 | src := newScanner(img) 596 | dstW := src.h 597 | dstH := src.w 598 | rowSize := dstW * 4 599 | dst := image.NewNRGBA(image.Rect(0, 0, dstW, dstH)) 600 | parallel(0, dstH, func(ys <-chan int) { 601 | for dstY := range ys { 602 | i := dstY * dst.Stride 603 | srcX := dstH - dstY - 1 604 | src.scan(srcX, 0, srcX+1, src.h, dst.Pix[i:i+rowSize]) 605 | reverse(dst.Pix[i : i+rowSize]) 606 | } 607 | }) 608 | return dst 609 | } 610 | 611 | // Rotate90 rotates the image 90 degrees counter-clockwise and returns the transformed image. 612 | func rotate90(img image.Image) *image.NRGBA { 613 | src := newScanner(img) 614 | dstW := src.h 615 | dstH := src.w 616 | rowSize := dstW * 4 617 | dst := image.NewNRGBA(image.Rect(0, 0, dstW, dstH)) 618 | parallel(0, dstH, func(ys <-chan int) { 619 | for dstY := range ys { 620 | i := dstY * dst.Stride 621 | srcX := dstH - dstY - 1 622 | src.scan(srcX, 0, srcX+1, src.h, dst.Pix[i:i+rowSize]) 623 | } 624 | }) 625 | return dst 626 | } 627 | 628 | // Rotate180 rotates the image 180 degrees counter-clockwise and returns the transformed image. 629 | func rotate180(img image.Image) *image.NRGBA { 630 | src := newScanner(img) 631 | dstW := src.w 632 | dstH := src.h 633 | rowSize := dstW * 4 634 | dst := image.NewNRGBA(image.Rect(0, 0, dstW, dstH)) 635 | parallel(0, dstH, func(ys <-chan int) { 636 | for dstY := range ys { 637 | i := dstY * dst.Stride 638 | srcY := dstH - dstY - 1 639 | src.scan(0, srcY, src.w, srcY+1, dst.Pix[i:i+rowSize]) 640 | reverse(dst.Pix[i : i+rowSize]) 641 | } 642 | }) 643 | return dst 644 | } 645 | 646 | // Rotate270 rotates the image 270 degrees counter-clockwise and returns the transformed image. 647 | func rotate270(img image.Image) *image.NRGBA { 648 | src := newScanner(img) 649 | dstW := src.h 650 | dstH := src.w 651 | rowSize := dstW * 4 652 | dst := image.NewNRGBA(image.Rect(0, 0, dstW, dstH)) 653 | parallel(0, dstH, func(ys <-chan int) { 654 | for dstY := range ys { 655 | i := dstY * dst.Stride 656 | srcX := dstY 657 | src.scan(srcX, 0, srcX+1, src.h, dst.Pix[i:i+rowSize]) 658 | reverse(dst.Pix[i : i+rowSize]) 659 | } 660 | }) 661 | return dst 662 | } 663 | 664 | // Rotate rotates an image by the given angle counter-clockwise . 665 | // The angle parameter is the rotation angle in degrees. 666 | // The bgColor parameter specifies the color of the uncovered zone after the rotation. 667 | func rotate(img image.Image, angle float64, bgColor color.Color) *image.NRGBA { 668 | angle = angle - math.Floor(angle/360)*360 669 | 670 | switch angle { 671 | case 0: 672 | return clone(img) 673 | case 90: 674 | return rotate90(img) 675 | case 180: 676 | return rotate180(img) 677 | case 270: 678 | return rotate270(img) 679 | } 680 | 681 | src := toNRGBA(img) 682 | srcW := src.Bounds().Max.X 683 | srcH := src.Bounds().Max.Y 684 | dstW, dstH := rotatedSize(srcW, srcH, angle) 685 | dst := image.NewNRGBA(image.Rect(0, 0, dstW, dstH)) 686 | 687 | if dstW <= 0 || dstH <= 0 { 688 | return dst 689 | } 690 | 691 | srcXOff := float64(srcW)/2 - 0.5 692 | srcYOff := float64(srcH)/2 - 0.5 693 | dstXOff := float64(dstW)/2 - 0.5 694 | dstYOff := float64(dstH)/2 - 0.5 695 | 696 | bgColorNRGBA := color.NRGBAModel.Convert(bgColor).(color.NRGBA) 697 | sin, cos := math.Sincos(math.Pi * angle / 180) 698 | 699 | parallel(0, dstH, func(ys <-chan int) { 700 | for dstY := range ys { 701 | for dstX := 0; dstX < dstW; dstX++ { 702 | xf, yf := rotatePoint(float64(dstX)-dstXOff, float64(dstY)-dstYOff, sin, cos) 703 | xf, yf = xf+srcXOff, yf+srcYOff 704 | interpolatePoint(dst, dstX, dstY, src, xf, yf, bgColorNRGBA) 705 | } 706 | } 707 | }) 708 | 709 | return dst 710 | } 711 | 712 | func rotatePoint(x, y, sin, cos float64) (float64, float64) { 713 | return x*cos - y*sin, x*sin + y*cos 714 | } 715 | 716 | func rotatedSize(w, h int, angle float64) (int, int) { 717 | if w <= 0 || h <= 0 { 718 | return 0, 0 719 | } 720 | 721 | sin, cos := math.Sincos(math.Pi * angle / 180) 722 | x1, y1 := rotatePoint(float64(w-1), 0, sin, cos) 723 | x2, y2 := rotatePoint(float64(w-1), float64(h-1), sin, cos) 724 | x3, y3 := rotatePoint(0, float64(h-1), sin, cos) 725 | 726 | minx := math.Min(x1, math.Min(x2, math.Min(x3, 0))) 727 | maxx := math.Max(x1, math.Max(x2, math.Max(x3, 0))) 728 | miny := math.Min(y1, math.Min(y2, math.Min(y3, 0))) 729 | maxy := math.Max(y1, math.Max(y2, math.Max(y3, 0))) 730 | 731 | neww := maxx - minx + 1 732 | if neww-math.Floor(neww) > 0.1 { 733 | neww++ 734 | } 735 | newh := maxy - miny + 1 736 | if newh-math.Floor(newh) > 0.1 { 737 | newh++ 738 | } 739 | 740 | return int(neww), int(newh) 741 | } 742 | 743 | func interpolatePoint(dst *image.NRGBA, dstX, dstY int, src *image.NRGBA, xf, yf float64, bgColor color.NRGBA) { 744 | j := dstY*dst.Stride + dstX*4 745 | d := dst.Pix[j : j+4 : j+4] 746 | 747 | x0 := int(math.Floor(xf)) 748 | y0 := int(math.Floor(yf)) 749 | bounds := src.Bounds() 750 | if !image.Pt(x0, y0).In(image.Rect(bounds.Min.X-1, bounds.Min.Y-1, bounds.Max.X, bounds.Max.Y)) { 751 | d[0] = bgColor.R 752 | d[1] = bgColor.G 753 | d[2] = bgColor.B 754 | d[3] = bgColor.A 755 | return 756 | } 757 | 758 | xq := xf - float64(x0) 759 | yq := yf - float64(y0) 760 | points := [4]image.Point{ 761 | {x0, y0}, 762 | {x0 + 1, y0}, 763 | {x0, y0 + 1}, 764 | {x0 + 1, y0 + 1}, 765 | } 766 | weights := [4]float64{ 767 | (1 - xq) * (1 - yq), 768 | xq * (1 - yq), 769 | (1 - xq) * yq, 770 | xq * yq, 771 | } 772 | 773 | var r, g, b, a float64 774 | for i := 0; i < 4; i++ { 775 | p := points[i] 776 | w := weights[i] 777 | if p.In(bounds) { 778 | i := p.Y*src.Stride + p.X*4 779 | s := src.Pix[i : i+4 : i+4] 780 | wa := float64(s[3]) * w 781 | r += float64(s[0]) * wa 782 | g += float64(s[1]) * wa 783 | b += float64(s[2]) * wa 784 | a += wa 785 | } else { 786 | wa := float64(bgColor.A) * w 787 | r += float64(bgColor.R) * wa 788 | g += float64(bgColor.G) * wa 789 | b += float64(bgColor.B) * wa 790 | a += wa 791 | } 792 | } 793 | if a != 0 { 794 | aInv := 1 / a 795 | d[0] = clamp(r * aInv) 796 | d[1] = clamp(g * aInv) 797 | d[2] = clamp(b * aInv) 798 | d[3] = clamp(a) 799 | } 800 | } 801 | 802 | // Clone returns a copy of the given image. 803 | func clone(img image.Image) *image.NRGBA { 804 | src := newScanner(img) 805 | dst := image.NewNRGBA(image.Rect(0, 0, src.w, src.h)) 806 | size := src.w * 4 807 | parallel(0, src.h, func(ys <-chan int) { 808 | for y := range ys { 809 | i := y * dst.Stride 810 | src.scan(0, y, src.w, y+1, dst.Pix[i:i+size]) 811 | } 812 | }) 813 | return dst 814 | } 815 | 816 | // 817 | // transform.go 818 | // 819 | 820 | // orientation is an EXIF flag that specifies the transformation 821 | // that should be applied to image to display it correctly. 822 | type orientation int 823 | 824 | const ( 825 | orientationUnspecified = 0 826 | orientationNormal = 1 827 | orientationFlipH = 2 828 | orientationRotate180 = 3 829 | orientationFlipV = 4 830 | orientationTranspose = 5 831 | orientationRotate270 = 6 832 | orientationTransverse = 7 833 | orientationRotate90 = 8 834 | ) 835 | 836 | // readOrientation tries to read the orientation EXIF flag from image data in r. 837 | // If the EXIF data block is not found or the orientation flag is not found 838 | // or any other error occures while reading the data, it returns the 839 | // orientationUnspecified (0) value. 840 | func readOrientation(r io.Reader) orientation { 841 | const ( 842 | markerSOI = 0xffd8 843 | markerAPP1 = 0xffe1 844 | exifHeader = 0x45786966 845 | byteOrderBE = 0x4d4d 846 | byteOrderLE = 0x4949 847 | orientationTag = 0x0112 848 | ) 849 | 850 | // Check if JPEG SOI marker is present. 851 | var soi uint16 852 | if err := binary.Read(r, binary.BigEndian, &soi); err != nil { 853 | return orientationUnspecified 854 | } 855 | if soi != markerSOI { 856 | return orientationUnspecified // Missing JPEG SOI marker. 857 | } 858 | 859 | // Find JPEG APP1 marker. 860 | for { 861 | var marker, size uint16 862 | if err := binary.Read(r, binary.BigEndian, &marker); err != nil { 863 | return orientationUnspecified 864 | } 865 | if err := binary.Read(r, binary.BigEndian, &size); err != nil { 866 | return orientationUnspecified 867 | } 868 | if marker>>8 != 0xff { 869 | return orientationUnspecified // Invalid JPEG marker. 870 | } 871 | if marker == markerAPP1 { 872 | break 873 | } 874 | if size < 2 { 875 | return orientationUnspecified // Invalid block size. 876 | } 877 | if _, err := io.CopyN(io.Discard, r, int64(size-2)); err != nil { 878 | return orientationUnspecified 879 | } 880 | } 881 | 882 | // Check if EXIF header is present. 883 | var header uint32 884 | if err := binary.Read(r, binary.BigEndian, &header); err != nil { 885 | return orientationUnspecified 886 | } 887 | if header != exifHeader { 888 | return orientationUnspecified 889 | } 890 | if _, err := io.CopyN(io.Discard, r, 2); err != nil { 891 | return orientationUnspecified 892 | } 893 | 894 | // Read byte order information. 895 | var ( 896 | byteOrderTag uint16 897 | byteOrder binary.ByteOrder 898 | ) 899 | if err := binary.Read(r, binary.BigEndian, &byteOrderTag); err != nil { 900 | return orientationUnspecified 901 | } 902 | switch byteOrderTag { 903 | case byteOrderBE: 904 | byteOrder = binary.BigEndian 905 | case byteOrderLE: 906 | byteOrder = binary.LittleEndian 907 | default: 908 | return orientationUnspecified // Invalid byte order flag. 909 | } 910 | if _, err := io.CopyN(io.Discard, r, 2); err != nil { 911 | return orientationUnspecified 912 | } 913 | 914 | // Skip the EXIF offset. 915 | var offset uint32 916 | if err := binary.Read(r, byteOrder, &offset); err != nil { 917 | return orientationUnspecified 918 | } 919 | if offset < 8 { 920 | return orientationUnspecified // Invalid offset value. 921 | } 922 | if _, err := io.CopyN(io.Discard, r, int64(offset-8)); err != nil { 923 | return orientationUnspecified 924 | } 925 | 926 | // Read the number of tags. 927 | var numTags uint16 928 | if err := binary.Read(r, byteOrder, &numTags); err != nil { 929 | return orientationUnspecified 930 | } 931 | 932 | // Find the orientation tag. 933 | for i := 0; i < int(numTags); i++ { 934 | var tag uint16 935 | if err := binary.Read(r, byteOrder, &tag); err != nil { 936 | return orientationUnspecified 937 | } 938 | if tag != orientationTag { 939 | if _, err := io.CopyN(io.Discard, r, 10); err != nil { 940 | return orientationUnspecified 941 | } 942 | continue 943 | } 944 | if _, err := io.CopyN(io.Discard, r, 6); err != nil { 945 | return orientationUnspecified 946 | } 947 | var val uint16 948 | if err := binary.Read(r, byteOrder, &val); err != nil { 949 | return orientationUnspecified 950 | } 951 | if val < 1 || val > 8 { 952 | return orientationUnspecified // Invalid tag value. 953 | } 954 | return orientation(val) 955 | } 956 | return orientationUnspecified // Missing orientation tag. 957 | } 958 | 959 | // fixOrientation applies a transform to img corresponding to the given orientation flag. 960 | func fixOrientation(img image.Image, o orientation) image.Image { 961 | switch o { 962 | case orientationNormal: 963 | case orientationFlipH: 964 | img = flipH(img) 965 | case orientationFlipV: 966 | img = flipV(img) 967 | case orientationRotate90: 968 | img = rotate90(img) 969 | case orientationRotate180: 970 | img = rotate180(img) 971 | case orientationRotate270: 972 | img = rotate270(img) 973 | case orientationTranspose: 974 | img = transpose(img) 975 | case orientationTransverse: 976 | img = transverse(img) 977 | } 978 | return img 979 | } 980 | 981 | // 982 | // utils.go 983 | // 984 | 985 | var maxProcs int64 986 | 987 | // parallel processes the data in separate goroutines. 988 | func parallel(start, stop int, fn func(<-chan int)) { 989 | count := stop - start 990 | if count < 1 { 991 | return 992 | } 993 | 994 | procs := runtime.GOMAXPROCS(0) 995 | limit := int(atomic.LoadInt64(&maxProcs)) 996 | if procs > limit && limit > 0 { 997 | procs = limit 998 | } 999 | if procs > count { 1000 | procs = count 1001 | } 1002 | 1003 | c := make(chan int, count) 1004 | for i := start; i < stop; i++ { 1005 | c <- i 1006 | } 1007 | close(c) 1008 | 1009 | var wg sync.WaitGroup 1010 | for i := 0; i < procs; i++ { 1011 | wg.Add(1) 1012 | go func() { 1013 | defer wg.Done() 1014 | fn(c) 1015 | }() 1016 | } 1017 | wg.Wait() 1018 | } 1019 | 1020 | // clamp rounds and clamps float64 value to fit into uint8. 1021 | func clamp(x float64) uint8 { 1022 | v := int64(x + 0.5) 1023 | if v > 255 { 1024 | return 255 1025 | } 1026 | if v > 0 { 1027 | return uint8(v) 1028 | } 1029 | return 0 1030 | } 1031 | 1032 | func reverse(pix []uint8) { 1033 | if len(pix) <= 4 { 1034 | return 1035 | } 1036 | i := 0 1037 | j := len(pix) - 4 1038 | for i < j { 1039 | pi := pix[i : i+4 : i+4] 1040 | pj := pix[j : j+4 : j+4] 1041 | pi[0], pj[0] = pj[0], pi[0] 1042 | pi[1], pj[1] = pj[1], pi[1] 1043 | pi[2], pj[2] = pj[2], pi[2] 1044 | pi[3], pj[3] = pj[3], pi[3] 1045 | i += 4 1046 | j -= 4 1047 | } 1048 | } 1049 | 1050 | func toNRGBA(img image.Image) *image.NRGBA { 1051 | if img, ok := img.(*image.NRGBA); ok { 1052 | return &image.NRGBA{ 1053 | Pix: img.Pix, 1054 | Stride: img.Stride, 1055 | Rect: img.Rect.Sub(img.Rect.Min), 1056 | } 1057 | } 1058 | return clone(img) 1059 | } 1060 | -------------------------------------------------------------------------------- /option.go: -------------------------------------------------------------------------------- 1 | package imgconv 2 | 3 | import ( 4 | "image" 5 | "io" 6 | "path/filepath" 7 | ) 8 | 9 | const defaultOpacity = 128 10 | 11 | var defaultFormat = &FormatOption{Format: JPEG} 12 | 13 | // Options represents options that can be used to configure a image operation. 14 | type Options struct { 15 | Watermark *WatermarkOption 16 | Resize *ResizeOption 17 | Format *FormatOption 18 | Gray bool 19 | } 20 | 21 | // NewOptions creates a new option with default setting. 22 | func NewOptions() *Options { 23 | return &Options{Format: defaultFormat} 24 | } 25 | 26 | // SetWatermark sets the value for the Watermark field. 27 | func (opts *Options) SetWatermark(mark image.Image, opacity uint) *Options { 28 | opts.Watermark = &WatermarkOption{Mark: mark} 29 | if opacity == 0 { 30 | opts.Watermark.Opacity = defaultOpacity 31 | } else { 32 | opts.Watermark.Opacity = uint8(opacity) 33 | } 34 | 35 | return opts 36 | } 37 | 38 | // SetResize sets the value for the Resize field. 39 | func (opts *Options) SetResize(width, height int, percent float64) *Options { 40 | opts.Resize = &ResizeOption{Width: width, Height: height, Percent: percent} 41 | return opts 42 | } 43 | 44 | // SetFormat sets the value for the Format field. 45 | func (opts *Options) SetFormat(f Format, options ...EncodeOption) *Options { 46 | opts.Format = &FormatOption{f, options} 47 | return opts 48 | } 49 | 50 | // SetGray sets the value for the Gray field. 51 | func (opts *Options) SetGray(gray bool) *Options { 52 | opts.Gray = gray 53 | return opts 54 | } 55 | 56 | // Convert image according options opts. 57 | func (opts *Options) Convert(w io.Writer, base image.Image) error { 58 | if opts.Gray { 59 | base = ToGray(base) 60 | } 61 | if opts.Resize != nil { 62 | base = opts.Resize.do(base) 63 | } 64 | if opts.Watermark != nil { 65 | base = opts.Watermark.do(base) 66 | } 67 | 68 | if opts.Format == nil { 69 | opts.Format = defaultFormat 70 | } 71 | 72 | return opts.Format.Encode(w, base) 73 | } 74 | 75 | // ConvertExt convert filename's ext according image format. 76 | func (opts *Options) ConvertExt(filename string) string { 77 | return filename[0:len(filename)-len(filepath.Ext(filename))] + "." + formatExts[opts.Format.Format][0] 78 | } 79 | -------------------------------------------------------------------------------- /option_test.go: -------------------------------------------------------------------------------- 1 | package imgconv 2 | 3 | import ( 4 | "bytes" 5 | "image" 6 | "io" 7 | "testing" 8 | ) 9 | 10 | func TestOption(t *testing.T) { 11 | mark := &image.NRGBA{ 12 | Rect: image.Rect(0, 0, 4, 4), 13 | Stride: 4 * 4, 14 | Pix: []uint8{ 15 | 0x00, 0x00, 0x00, 0x00, 0xff, 0x00, 0x00, 0x40, 0xff, 0x00, 0x00, 0xbf, 0xff, 0x00, 0x00, 0xff, 16 | 0x00, 0xff, 0x00, 0x40, 0x6e, 0x6d, 0x25, 0x70, 0xb0, 0x14, 0x3b, 0xcf, 0xbf, 0x00, 0x40, 0xff, 17 | 0x00, 0xff, 0x00, 0xbf, 0x14, 0xb0, 0x3b, 0xcf, 0x33, 0x33, 0x99, 0xef, 0x40, 0x00, 0xbf, 0xff, 18 | 0x00, 0xff, 0x00, 0xff, 0x00, 0xbf, 0x40, 0xff, 0x00, 0x40, 0xbf, 0xff, 0x00, 0x00, 0xff, 0xff, 19 | }, 20 | } 21 | 22 | opts := NewOptions() 23 | if opts.Format.Format != JPEG { 24 | t.Fatal("Format is not expect one.") 25 | } 26 | opts.SetWatermark(mark, 100) 27 | if mark != opts.Watermark.Mark || opts.Watermark.Opacity != 100 { 28 | t.Fatal("SetWatermark result is not expect one.") 29 | } 30 | opts.SetWatermark(mark, 0) 31 | if mark != opts.Watermark.Mark || opts.Watermark.Opacity != 128 { 32 | t.Fatal("SetWatermark result is not expect one.") 33 | } 34 | opts.SetResize(0, 0, 33) 35 | if opts.Resize.Width != 0 || opts.Resize.Height != 0 || opts.Resize.Percent != 33 { 36 | t.Fatal("SetResize result is not expect one.") 37 | } 38 | opts.SetGray(true) 39 | if !opts.Gray { 40 | t.Fatal("SetGray result is not expect one.") 41 | } 42 | if err := opts.Convert(io.Discard, mark); err != nil { 43 | t.Fatal("Failed to Convert.") 44 | } 45 | } 46 | 47 | func TestConvert(t *testing.T) { 48 | base, err := Open("testdata/video-001.png") 49 | if err != nil { 50 | t.Fatal(err) 51 | } 52 | 53 | var buf1, buf2 bytes.Buffer 54 | opts := NewOptions() 55 | if err := opts.Convert(&buf1, base); err != nil { 56 | t.Fatal("Failed to Convert.") 57 | } 58 | opts = &Options{Format: &FormatOption{}} 59 | if err := opts.Convert(&buf2, base); err != nil { 60 | t.Fatal("Failed to Convert.") 61 | } 62 | 63 | if !bytes.Equal(buf1.Bytes(), buf2.Bytes()) { 64 | t.Fatal("Convert get different result") 65 | } 66 | } 67 | 68 | func TestConvertExt(t *testing.T) { 69 | opts := NewOptions().SetFormat(TIFF) 70 | if opts.ConvertExt("testdata/video-001.png") != "testdata/video-001.tif" { 71 | t.Fatal("ConvertExt result is not expect one.") 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /resize.go: -------------------------------------------------------------------------------- 1 | package imgconv 2 | 3 | import "image" 4 | 5 | // ResizeOption is resize option 6 | type ResizeOption struct { 7 | Width int 8 | Height int 9 | Percent float64 10 | } 11 | 12 | // Resize resizes image 13 | func Resize(base image.Image, option *ResizeOption) image.Image { 14 | return option.do(base) 15 | } 16 | 17 | func (r *ResizeOption) do(base image.Image) image.Image { 18 | if r.Width == 0 && r.Height == 0 { 19 | return resize(base, int(float64(base.Bounds().Dx())*r.Percent/100), 0, lanczos) 20 | } 21 | 22 | return resize(base, r.Width, r.Height, lanczos) 23 | } 24 | -------------------------------------------------------------------------------- /resize_test.go: -------------------------------------------------------------------------------- 1 | package imgconv 2 | 3 | import ( 4 | "image" 5 | "testing" 6 | ) 7 | 8 | func compare(t *testing.T, img0, img1 image.Image) { 9 | t.Helper() 10 | b0 := img0.Bounds() 11 | b1 := img1.Bounds() 12 | if b0.Dx() != b1.Dx() || b0.Dy() != b1.Dy() { 13 | t.Fatalf("wrong image size: want %s, got %s", b0, b1) 14 | } 15 | x1 := b1.Min.X - b0.Min.X 16 | y1 := b1.Min.Y - b0.Min.Y 17 | for y := b0.Min.Y; y < b0.Max.Y; y++ { 18 | for x := b0.Min.X; x < b0.Max.X; x++ { 19 | c0 := img0.At(x, y) 20 | c1 := img1.At(x+x1, y+y1) 21 | r0, g0, b0, a0 := c0.RGBA() 22 | r1, g1, b1, a1 := c1.RGBA() 23 | if r0 != r1 || g0 != g1 || b0 != b1 || a0 != a1 { 24 | t.Fatalf("pixel at (%d, %d) has wrong color: want %v, got %v", x, y, c0, c1) 25 | } 26 | } 27 | } 28 | } 29 | 30 | func TestResize(t *testing.T) { 31 | testCase := []struct { 32 | option *ResizeOption 33 | want image.Point 34 | }{ 35 | {&ResizeOption{Width: 300}, image.Pt(300, 206)}, 36 | {&ResizeOption{Height: 206}, image.Pt(300, 206)}, 37 | {&ResizeOption{Width: 200, Height: 200}, image.Pt(200, 200)}, 38 | {&ResizeOption{Percent: 50}, image.Pt(75, 52)}, 39 | } 40 | 41 | // Read the image. 42 | sample, err := Open("testdata/video-001.png") 43 | if err != nil { 44 | t.Fatal(err) 45 | } 46 | 47 | for _, tc := range testCase { 48 | // Resize the image. 49 | img0 := tc.option.do(sample) 50 | if img0.Bounds().Size() != tc.want { 51 | t.Fatalf("bounds differ: %v and %v", img0.Bounds().Size(), tc.want) 52 | } 53 | img1 := Resize(sample, tc.option) 54 | 55 | compare(t, img0, img1) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /split.go: -------------------------------------------------------------------------------- 1 | package imgconv 2 | 3 | import ( 4 | "errors" 5 | "image" 6 | ) 7 | 8 | // SplitMode defines the mode in which the image will be split 9 | type SplitMode int 10 | 11 | const ( 12 | // SplitHorizontalMode splits the image horizontally 13 | SplitHorizontalMode SplitMode = iota 14 | // SplitVerticalMode splits the image vertically 15 | SplitVerticalMode 16 | ) 17 | 18 | func split(base image.Rectangle, n int, mode SplitMode) (rects []image.Rectangle) { 19 | var width, height int 20 | if mode == SplitHorizontalMode { 21 | width = base.Dx() / n 22 | height = base.Dy() 23 | } else { 24 | width = base.Dx() 25 | height = base.Dy() / n 26 | } 27 | if width == 0 || height == 0 { 28 | return 29 | } 30 | for i := range n { 31 | var r image.Rectangle 32 | if mode == SplitHorizontalMode { 33 | r = image.Rect( 34 | base.Min.X+width*i, base.Min.Y, 35 | base.Min.X+width*(i+1), base.Min.Y+height, 36 | ) 37 | } else { 38 | r = image.Rect( 39 | base.Min.X, base.Min.Y+height*i, 40 | base.Min.X+width, base.Min.Y+height*(i+1), 41 | ) 42 | } 43 | rects = append(rects, r) 44 | } 45 | return 46 | } 47 | 48 | // Split splits an image into n smaller images based on the specified split mode. 49 | // If n is less than 1, or the image cannot be split, it returns an error. 50 | func Split(base image.Image, n int, mode SplitMode) (imgs []image.Image, err error) { 51 | if n < 1 { 52 | return nil, errors.New("invalid number of parts: must be at least 1") 53 | } 54 | if img, ok := base.(interface { 55 | SubImage(image.Rectangle) image.Image 56 | }); ok { 57 | rects := split(base.Bounds(), n, mode) 58 | if len(rects) == 0 { 59 | return nil, errors.New("failed to split the image: invalid dimensions or n is too large") 60 | } 61 | for _, rect := range rects { 62 | imgs = append(imgs, img.SubImage(rect)) 63 | } 64 | } else { 65 | return nil, errors.New("image type does not support SubImage extraction") 66 | } 67 | return 68 | } 69 | 70 | // SplitHorizontal splits an image into n parts horizontally. 71 | func SplitHorizontal(base image.Image, n int) ([]image.Image, error) { 72 | return Split(base, n, SplitHorizontalMode) 73 | } 74 | 75 | // SplitVertical splits an image into n parts vertically. 76 | func SplitVertical(base image.Image, n int) ([]image.Image, error) { 77 | return Split(base, n, SplitVerticalMode) 78 | } 79 | -------------------------------------------------------------------------------- /split_test.go: -------------------------------------------------------------------------------- 1 | package imgconv 2 | 3 | import ( 4 | "image" 5 | "slices" 6 | "testing" 7 | ) 8 | 9 | func TestSplit(t *testing.T) { 10 | for i, testcase := range []struct { 11 | base image.Rectangle 12 | n int 13 | mode SplitMode 14 | want []image.Rectangle 15 | }{ 16 | {image.Rect(0, 0, 100, 100), 4, SplitHorizontalMode, []image.Rectangle{ 17 | image.Rect(0, 0, 25, 100), 18 | image.Rect(25, 0, 50, 100), 19 | image.Rect(50, 0, 75, 100), 20 | image.Rect(75, 0, 100, 100), 21 | }}, 22 | {image.Rect(0, 0, 100, 100), 4, SplitVerticalMode, []image.Rectangle{ 23 | image.Rect(0, 0, 100, 25), 24 | image.Rect(0, 25, 100, 50), 25 | image.Rect(0, 50, 100, 75), 26 | image.Rect(0, 75, 100, 100), 27 | }}, 28 | {image.Rect(100, 100, 200, 200), 4, SplitHorizontalMode, []image.Rectangle{ 29 | image.Rect(100, 100, 125, 200), 30 | image.Rect(125, 100, 150, 200), 31 | image.Rect(150, 100, 175, 200), 32 | image.Rect(175, 100, 200, 200), 33 | }}, 34 | {image.Rect(100, 100, 200, 200), 4, SplitVerticalMode, []image.Rectangle{ 35 | image.Rect(100, 100, 200, 125), 36 | image.Rect(100, 125, 200, 150), 37 | image.Rect(100, 150, 200, 175), 38 | image.Rect(100, 175, 200, 200), 39 | }}, 40 | } { 41 | if rects := split(testcase.base, testcase.n, testcase.mode); slices.CompareFunc( 42 | rects, 43 | testcase.want, 44 | func(a, b image.Rectangle) int { 45 | if a.Eq(b) { 46 | return 0 47 | } 48 | return 1 49 | }, 50 | ) != 0 { 51 | t.Errorf("#%d wrong split results: want %v, got %v", i, testcase.want, rects) 52 | } 53 | } 54 | } 55 | 56 | func TestSplitError(t *testing.T) { 57 | r := image.Rect(0, 0, 100, 100) 58 | img := image.NewNRGBA(r) 59 | if _, err := Split(img, 10, SplitHorizontalMode); err != nil { 60 | t.Fatal(err) 61 | } 62 | for i, testcase := range []struct { 63 | img image.Image 64 | n int 65 | }{ 66 | {r, 10}, 67 | {img, 0}, 68 | {img, 101}, 69 | {image.NewNRGBA(image.Rectangle{}), 10}, 70 | } { 71 | if _, err := Split(testcase.img, testcase.n, SplitHorizontalMode); err == nil { 72 | t.Errorf("#%d want error, got nil", i) 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /testdata/video-001.bmp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunshineplan/imgconv/47774aaac92fca9176403bf6c06316c991a47e58/testdata/video-001.bmp -------------------------------------------------------------------------------- /testdata/video-001.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunshineplan/imgconv/47774aaac92fca9176403bf6c06316c991a47e58/testdata/video-001.gif -------------------------------------------------------------------------------- /testdata/video-001.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunshineplan/imgconv/47774aaac92fca9176403bf6c06316c991a47e58/testdata/video-001.jpg -------------------------------------------------------------------------------- /testdata/video-001.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunshineplan/imgconv/47774aaac92fca9176403bf6c06316c991a47e58/testdata/video-001.pdf -------------------------------------------------------------------------------- /testdata/video-001.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunshineplan/imgconv/47774aaac92fca9176403bf6c06316c991a47e58/testdata/video-001.png -------------------------------------------------------------------------------- /testdata/video-001.tif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunshineplan/imgconv/47774aaac92fca9176403bf6c06316c991a47e58/testdata/video-001.tif -------------------------------------------------------------------------------- /testdata/video-001.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunshineplan/imgconv/47774aaac92fca9176403bf6c06316c991a47e58/testdata/video-001.webp -------------------------------------------------------------------------------- /watermark.go: -------------------------------------------------------------------------------- 1 | package imgconv 2 | 3 | import ( 4 | "image" 5 | "image/color" 6 | "image/draw" 7 | "math/rand/v2" 8 | ) 9 | 10 | // WatermarkOption is watermark option 11 | type WatermarkOption struct { 12 | Mark image.Image 13 | Opacity uint8 14 | Random bool 15 | Offset image.Point 16 | } 17 | 18 | // Watermark add watermark to image 19 | func Watermark(base image.Image, option *WatermarkOption) image.Image { 20 | return option.do(base) 21 | } 22 | 23 | // SetRandom sets the option for the Watermark position random or not. 24 | func (w *WatermarkOption) SetRandom(random bool) *WatermarkOption { 25 | w.Random = random 26 | return w 27 | } 28 | 29 | // SetOffset sets the option for the Watermark offset base center when adding fixed watermark. 30 | func (w *WatermarkOption) SetOffset(offset image.Point) *WatermarkOption { 31 | w.Offset = offset 32 | return w 33 | } 34 | 35 | func (w *WatermarkOption) do(base image.Image) image.Image { 36 | img := image.NewNRGBA(base.Bounds()) 37 | draw.Draw(img, img.Bounds(), base, image.Point{}, draw.Src) 38 | var offset image.Point 39 | var mark image.Image 40 | if w.Random { 41 | if w.Mark.Bounds().Dx() >= base.Bounds().Dx()/3 || w.Mark.Bounds().Dy() >= base.Bounds().Dy()/3 { 42 | if calcResizeXY(base.Bounds(), w.Mark.Bounds()) { 43 | mark = Resize(w.Mark, &ResizeOption{Width: base.Bounds().Dx() / 3}) 44 | } else { 45 | mark = Resize(w.Mark, &ResizeOption{Height: base.Bounds().Dy() / 3}) 46 | } 47 | } else { 48 | mark = w.Mark 49 | } 50 | mark = rotate(mark, float64(randRange(-30, 30))+rand.Float64(), color.Transparent) 51 | offset = image.Pt( 52 | randRange(base.Bounds().Dx()/6, base.Bounds().Dx()*5/6-mark.Bounds().Dx()), 53 | randRange(base.Bounds().Dy()/6, base.Bounds().Dy()*5/6-mark.Bounds().Dy())) 54 | } else { 55 | mark = w.Mark 56 | offset = image.Pt( 57 | (base.Bounds().Dx()/2)-(mark.Bounds().Dx()/2)+w.Offset.X, 58 | (base.Bounds().Dy()/2)-(mark.Bounds().Dy()/2)+w.Offset.Y) 59 | } 60 | 61 | draw.DrawMask( 62 | img, 63 | mark.Bounds().Add(offset), 64 | mark, 65 | image.Point{}, 66 | image.NewUniform(color.Alpha{w.Opacity}), 67 | image.Point{}, 68 | draw.Over, 69 | ) 70 | 71 | return img 72 | } 73 | 74 | func randRange(min, max int) int { 75 | if max < min { 76 | min, max = max, min 77 | } 78 | return rand.N(max-min+1) + min 79 | } 80 | 81 | func calcResizeXY(base, mark image.Rectangle) bool { 82 | return base.Dx()*mark.Dy()/mark.Dx() < base.Dy() 83 | } 84 | -------------------------------------------------------------------------------- /watermark_test.go: -------------------------------------------------------------------------------- 1 | package imgconv 2 | 3 | import ( 4 | "image" 5 | "reflect" 6 | "slices" 7 | "testing" 8 | "time" 9 | ) 10 | 11 | func TestWatermark(t *testing.T) { 12 | mark := &image.NRGBA{ 13 | Rect: image.Rect(0, 0, 4, 4), 14 | Stride: 4 * 4, 15 | Pix: []uint8{ 16 | 0x00, 0x00, 0x00, 0x00, 0xff, 0x00, 0x00, 0x40, 0xff, 0x00, 0x00, 0xbf, 0xff, 0x00, 0x00, 0xff, 17 | 0x00, 0xff, 0x00, 0x40, 0x6e, 0x6d, 0x25, 0x70, 0xb0, 0x14, 0x3b, 0xcf, 0xbf, 0x00, 0x40, 0xff, 18 | 0x00, 0xff, 0x00, 0xbf, 0x14, 0xb0, 0x3b, 0xcf, 0x33, 0x33, 0x99, 0xef, 0x40, 0x00, 0xbf, 0xff, 19 | 0x00, 0xff, 0x00, 0xff, 0x00, 0xbf, 0x40, 0xff, 0x00, 0x40, 0xbf, 0xff, 0x00, 0x00, 0xff, 0xff, 20 | }, 21 | } 22 | 23 | // Read the image. 24 | sample, err := Open("testdata/video-001.png") 25 | if err != nil { 26 | t.Fatal("testdata/video-001.png", err) 27 | } 28 | 29 | m0 := (&WatermarkOption{Mark: mark, Opacity: 50}).SetOffset(image.Pt(5, 5)).do(sample) 30 | m1 := Watermark(sample, &WatermarkOption{Mark: mark, Opacity: 50, Offset: image.Pt(5, 5)}) 31 | if !reflect.DeepEqual(m0, m1) { 32 | t.Fatal("Fixed Watermark got different images") 33 | } 34 | 35 | m0 = (&WatermarkOption{Mark: mark, Opacity: 50}).SetRandom(true).do(sample) 36 | time.Sleep(time.Nanosecond) 37 | m1 = (&WatermarkOption{Mark: mark, Opacity: 50, Random: true}).do(sample) 38 | if reflect.DeepEqual(m0, m1) { 39 | t.Fatal("Random Watermark got same images") 40 | } 41 | 42 | (&WatermarkOption{Mark: sample, Random: true}).do(sample) 43 | (&WatermarkOption{Mark: sample, Random: true}).do(rotate90(sample)) 44 | } 45 | 46 | func TestCalcResizeXY(t *testing.T) { 47 | testCase := []struct { 48 | base image.Rectangle 49 | mark image.Rectangle 50 | want bool 51 | }{ 52 | {image.Rect(0, 0, 100, 50), image.Rect(0, 0, 200, 200), false}, 53 | {image.Rect(0, 0, 50, 100), image.Rect(0, 0, 200, 200), true}, 54 | } 55 | 56 | for _, tc := range testCase { 57 | if calcResizeXY(tc.base, tc.mark) != tc.want { 58 | t.Errorf("Want %v, got %v", tc.want, !tc.want) 59 | } 60 | } 61 | } 62 | 63 | func TestRandRange(t *testing.T) { 64 | testCase := []struct { 65 | min, max int 66 | res []int 67 | }{ 68 | {1, 5, []int{1, 2, 3, 4, 5}}, 69 | {5, 1, []int{1, 2, 3, 4, 5}}, 70 | {-1, 5, []int{-1, 0, 1, 2, 3, 4, 5}}, 71 | {5, -1, []int{-1, 0, 1, 2, 3, 4, 5}}, 72 | {-5, -1, []int{-5, -4, -3, -2, -1}}, 73 | {-1, -5, []int{-5, -4, -3, -2, -1}}, 74 | } 75 | 76 | for i := 0; i < 100; i++ { 77 | for i, tc := range testCase { 78 | if res := randRange(tc.min, tc.max); !slices.Contains(tc.res, res) { 79 | t.Errorf("#%d: got %d, not in range %v", i, res, tc.res) 80 | } 81 | } 82 | } 83 | } 84 | --------------------------------------------------------------------------------