Add pure Go image processor with resize and format conversion
Implements the Processor interface using disintegration/imaging library. Supports JPEG, PNG, GIF, WebP decoding and JPEG, PNG, GIF encoding. Includes all fit modes: cover, contain, fill, inside, outside.
This commit is contained in:
6
go.mod
6
go.mod
@@ -5,12 +5,14 @@ go 1.25.4
|
|||||||
require (
|
require (
|
||||||
git.eeqj.de/sneak/smartconfig v1.0.0
|
git.eeqj.de/sneak/smartconfig v1.0.0
|
||||||
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
|
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
|
||||||
|
github.com/disintegration/imaging v1.6.2
|
||||||
github.com/getsentry/sentry-go v0.40.0
|
github.com/getsentry/sentry-go v0.40.0
|
||||||
github.com/go-chi/chi/v5 v5.2.3
|
github.com/go-chi/chi/v5 v5.2.3
|
||||||
github.com/go-chi/cors v1.2.2
|
github.com/go-chi/cors v1.2.2
|
||||||
github.com/prometheus/client_golang v1.23.2
|
github.com/prometheus/client_golang v1.23.2
|
||||||
github.com/slok/go-http-metrics v0.13.0
|
github.com/slok/go-http-metrics v0.13.0
|
||||||
go.uber.org/fx v1.24.0
|
go.uber.org/fx v1.24.0
|
||||||
|
golang.org/x/image v0.34.0
|
||||||
modernc.org/sqlite v1.42.2
|
modernc.org/sqlite v1.42.2
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -124,10 +126,10 @@ require (
|
|||||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||||
golang.org/x/net v0.43.0 // indirect
|
golang.org/x/net v0.43.0 // indirect
|
||||||
golang.org/x/oauth2 v0.30.0 // indirect
|
golang.org/x/oauth2 v0.30.0 // indirect
|
||||||
golang.org/x/sync v0.16.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.36.0 // indirect
|
golang.org/x/sys v0.36.0 // indirect
|
||||||
golang.org/x/term v0.34.0 // indirect
|
golang.org/x/term v0.34.0 // indirect
|
||||||
golang.org/x/text v0.28.0 // indirect
|
golang.org/x/text v0.32.0 // indirect
|
||||||
golang.org/x/time v0.12.0 // indirect
|
golang.org/x/time v0.12.0 // indirect
|
||||||
google.golang.org/api v0.237.0 // indirect
|
google.golang.org/api v0.237.0 // indirect
|
||||||
google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2 // indirect
|
google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2 // indirect
|
||||||
|
|||||||
21
go.sum
21
go.sum
@@ -92,6 +92,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
|
|||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||||
|
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||||
|
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/emicklei/go-restful/v3 v3.12.1 h1:PJMDIM/ak7btuL8Ex0iYET9hxM3CI2sjZtzpL63nKAU=
|
github.com/emicklei/go-restful/v3 v3.12.1 h1:PJMDIM/ak7btuL8Ex0iYET9hxM3CI2sjZtzpL63nKAU=
|
||||||
@@ -420,10 +422,13 @@ golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
|||||||
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||||
|
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
|
golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8=
|
||||||
|
golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU=
|
||||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
|
||||||
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
|
||||||
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
@@ -443,8 +448,8 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ
|
|||||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
@@ -478,8 +483,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
|||||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
@@ -487,8 +492,8 @@ golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtn
|
|||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
|
||||||
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
|||||||
244
internal/imgcache/processor.go
Normal file
244
internal/imgcache/processor.go
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
package imgcache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/gif"
|
||||||
|
"image/jpeg"
|
||||||
|
"image/png"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/disintegration/imaging"
|
||||||
|
"golang.org/x/image/webp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ImageProcessor implements the Processor interface using pure Go libraries.
|
||||||
|
type ImageProcessor struct{}
|
||||||
|
|
||||||
|
// NewImageProcessor creates a new image processor.
|
||||||
|
func NewImageProcessor() *ImageProcessor {
|
||||||
|
return &ImageProcessor{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process transforms an image according to the request.
|
||||||
|
func (p *ImageProcessor) Process(
|
||||||
|
_ context.Context,
|
||||||
|
input io.Reader,
|
||||||
|
req *ImageRequest,
|
||||||
|
) (*ProcessResult, error) {
|
||||||
|
// Read input
|
||||||
|
data, err := io.ReadAll(input)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read input: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode image
|
||||||
|
img, format, err := p.decode(data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode image: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get original dimensions
|
||||||
|
bounds := img.Bounds()
|
||||||
|
origWidth := bounds.Dx()
|
||||||
|
origHeight := bounds.Dy()
|
||||||
|
|
||||||
|
// Determine target dimensions
|
||||||
|
targetWidth := req.Size.Width
|
||||||
|
targetHeight := req.Size.Height
|
||||||
|
|
||||||
|
// If both are 0, keep original size
|
||||||
|
if targetWidth == 0 && targetHeight == 0 {
|
||||||
|
targetWidth = origWidth
|
||||||
|
targetHeight = origHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resize if needed
|
||||||
|
if targetWidth != origWidth || targetHeight != origHeight {
|
||||||
|
img = p.resize(img, targetWidth, targetHeight, req.FitMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine output format
|
||||||
|
outputFormat := req.Format
|
||||||
|
if outputFormat == FormatOriginal || outputFormat == "" {
|
||||||
|
outputFormat = p.formatFromString(format)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode to target format
|
||||||
|
output, err := p.encode(img, outputFormat, req.Quality)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to encode: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
finalBounds := img.Bounds()
|
||||||
|
|
||||||
|
return &ProcessResult{
|
||||||
|
Content: io.NopCloser(bytes.NewReader(output)),
|
||||||
|
ContentLength: int64(len(output)),
|
||||||
|
ContentType: ImageFormatToMIME(outputFormat),
|
||||||
|
Width: finalBounds.Dx(),
|
||||||
|
Height: finalBounds.Dy(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SupportedInputFormats returns MIME types this processor can read.
|
||||||
|
func (p *ImageProcessor) SupportedInputFormats() []string {
|
||||||
|
return []string{
|
||||||
|
string(MIMETypeJPEG),
|
||||||
|
string(MIMETypePNG),
|
||||||
|
string(MIMETypeGIF),
|
||||||
|
string(MIMETypeWebP),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SupportedOutputFormats returns formats this processor can write.
|
||||||
|
func (p *ImageProcessor) SupportedOutputFormats() []ImageFormat {
|
||||||
|
return []ImageFormat{
|
||||||
|
FormatJPEG,
|
||||||
|
FormatPNG,
|
||||||
|
FormatGIF,
|
||||||
|
// WebP encoding not supported in pure Go, will fall back to PNG
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// decode decodes image data into an image.Image.
|
||||||
|
func (p *ImageProcessor) decode(data []byte) (image.Image, string, error) {
|
||||||
|
r := bytes.NewReader(data)
|
||||||
|
|
||||||
|
// Try standard formats first
|
||||||
|
img, format, err := image.Decode(r)
|
||||||
|
if err == nil {
|
||||||
|
return img, format, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try WebP
|
||||||
|
r.Reset(data)
|
||||||
|
|
||||||
|
img, err = webp.Decode(r)
|
||||||
|
if err == nil {
|
||||||
|
return img, "webp", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, "", fmt.Errorf("unsupported image format")
|
||||||
|
}
|
||||||
|
|
||||||
|
// resize resizes the image according to the fit mode.
|
||||||
|
func (p *ImageProcessor) resize(img image.Image, width, height int, fit FitMode) image.Image {
|
||||||
|
switch fit {
|
||||||
|
case FitCover:
|
||||||
|
// Resize and crop to fill exact dimensions
|
||||||
|
return imaging.Fill(img, width, height, imaging.Center, imaging.Lanczos)
|
||||||
|
|
||||||
|
case FitContain:
|
||||||
|
// Resize to fit within dimensions, maintaining aspect ratio
|
||||||
|
return imaging.Fit(img, width, height, imaging.Lanczos)
|
||||||
|
|
||||||
|
case FitFill:
|
||||||
|
// Resize to exact dimensions (may distort)
|
||||||
|
return imaging.Resize(img, width, height, imaging.Lanczos)
|
||||||
|
|
||||||
|
case FitInside:
|
||||||
|
// Same as contain, but only shrink
|
||||||
|
bounds := img.Bounds()
|
||||||
|
origW := bounds.Dx()
|
||||||
|
origH := bounds.Dy()
|
||||||
|
|
||||||
|
if origW <= width && origH <= height {
|
||||||
|
return img // Already fits
|
||||||
|
}
|
||||||
|
|
||||||
|
return imaging.Fit(img, width, height, imaging.Lanczos)
|
||||||
|
|
||||||
|
case FitOutside:
|
||||||
|
// Resize so smallest dimension fits, may exceed target on other dimension
|
||||||
|
bounds := img.Bounds()
|
||||||
|
origW := bounds.Dx()
|
||||||
|
origH := bounds.Dy()
|
||||||
|
|
||||||
|
scaleW := float64(width) / float64(origW)
|
||||||
|
scaleH := float64(height) / float64(origH)
|
||||||
|
|
||||||
|
scale := scaleW
|
||||||
|
if scaleH > scaleW {
|
||||||
|
scale = scaleH
|
||||||
|
}
|
||||||
|
|
||||||
|
newW := int(float64(origW) * scale)
|
||||||
|
newH := int(float64(origH) * scale)
|
||||||
|
|
||||||
|
return imaging.Resize(img, newW, newH, imaging.Lanczos)
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Default to cover
|
||||||
|
return imaging.Fill(img, width, height, imaging.Center, imaging.Lanczos)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultQuality = 85
|
||||||
|
|
||||||
|
// encode encodes an image to the specified format.
|
||||||
|
func (p *ImageProcessor) encode(img image.Image, format ImageFormat, quality int) ([]byte, error) {
|
||||||
|
if quality <= 0 {
|
||||||
|
quality = defaultQuality
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
switch format {
|
||||||
|
case FormatJPEG:
|
||||||
|
err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: quality})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
case FormatPNG:
|
||||||
|
err := png.Encode(&buf, img)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
case FormatGIF:
|
||||||
|
err := gif.Encode(&buf, img, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
case FormatWebP:
|
||||||
|
// Pure Go doesn't have WebP encoder, fall back to PNG
|
||||||
|
// TODO: Add WebP encoding support via external library
|
||||||
|
err := png.Encode(&buf, img)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
case FormatAVIF:
|
||||||
|
// AVIF not supported in pure Go, fall back to JPEG
|
||||||
|
err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: quality})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported output format: %s", format)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatFromString converts a format string to ImageFormat.
|
||||||
|
func (p *ImageProcessor) formatFromString(format string) ImageFormat {
|
||||||
|
switch format {
|
||||||
|
case "jpeg":
|
||||||
|
return FormatJPEG
|
||||||
|
case "png":
|
||||||
|
return FormatPNG
|
||||||
|
case "gif":
|
||||||
|
return FormatGIF
|
||||||
|
case "webp":
|
||||||
|
return FormatWebP
|
||||||
|
default:
|
||||||
|
return FormatJPEG
|
||||||
|
}
|
||||||
|
}
|
||||||
242
internal/imgcache/processor_test.go
Normal file
242
internal/imgcache/processor_test.go
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
package imgcache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/jpeg"
|
||||||
|
"image/png"
|
||||||
|
"io"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// createTestJPEG creates a simple test JPEG image.
|
||||||
|
func createTestJPEG(t *testing.T, width, height int) []byte {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||||
|
// Fill with a gradient
|
||||||
|
for y := 0; y < height; y++ {
|
||||||
|
for x := 0; x < width; x++ {
|
||||||
|
img.Set(x, y, color.RGBA{
|
||||||
|
R: uint8(x * 255 / width),
|
||||||
|
G: uint8(y * 255 / height),
|
||||||
|
B: 128,
|
||||||
|
A: 255,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 90}); err != nil {
|
||||||
|
t.Fatalf("failed to encode test JPEG: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// createTestPNG creates a simple test PNG image.
|
||||||
|
func createTestPNG(t *testing.T, width, height int) []byte {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||||
|
for y := 0; y < height; y++ {
|
||||||
|
for x := 0; x < width; x++ {
|
||||||
|
img.Set(x, y, color.RGBA{
|
||||||
|
R: uint8(x * 255 / width),
|
||||||
|
G: uint8(y * 255 / height),
|
||||||
|
B: 128,
|
||||||
|
A: 255,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := png.Encode(&buf, img); err != nil {
|
||||||
|
t.Fatalf("failed to encode test PNG: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImageProcessor_ResizeJPEG(t *testing.T) {
|
||||||
|
proc := NewImageProcessor()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
input := createTestJPEG(t, 800, 600)
|
||||||
|
|
||||||
|
req := &ImageRequest{
|
||||||
|
Size: Size{Width: 400, Height: 300},
|
||||||
|
Format: FormatJPEG,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: FitCover,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := proc.Process(ctx, bytes.NewReader(input), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Process() error = %v", err)
|
||||||
|
}
|
||||||
|
defer result.Content.Close()
|
||||||
|
|
||||||
|
if result.Width != 400 {
|
||||||
|
t.Errorf("Process() width = %d, want 400", result.Width)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Height != 300 {
|
||||||
|
t.Errorf("Process() height = %d, want 300", result.Height)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.ContentLength == 0 {
|
||||||
|
t.Error("Process() returned zero content length")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify it's valid JPEG by reading the content
|
||||||
|
data, err := io.ReadAll(result.Content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read result: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mime, err := DetectFormat(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DetectFormat() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if mime != MIMETypeJPEG {
|
||||||
|
t.Errorf("Output format = %v, want %v", mime, MIMETypeJPEG)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImageProcessor_ConvertToPNG(t *testing.T) {
|
||||||
|
proc := NewImageProcessor()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
input := createTestJPEG(t, 200, 150)
|
||||||
|
|
||||||
|
req := &ImageRequest{
|
||||||
|
Size: Size{Width: 200, Height: 150},
|
||||||
|
Format: FormatPNG,
|
||||||
|
FitMode: FitCover,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := proc.Process(ctx, bytes.NewReader(input), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Process() error = %v", err)
|
||||||
|
}
|
||||||
|
defer result.Content.Close()
|
||||||
|
|
||||||
|
data, err := io.ReadAll(result.Content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read result: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mime, err := DetectFormat(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DetectFormat() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if mime != MIMETypePNG {
|
||||||
|
t.Errorf("Output format = %v, want %v", mime, MIMETypePNG)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImageProcessor_OriginalSize(t *testing.T) {
|
||||||
|
proc := NewImageProcessor()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
input := createTestJPEG(t, 640, 480)
|
||||||
|
|
||||||
|
req := &ImageRequest{
|
||||||
|
Size: Size{Width: 0, Height: 0}, // Original size
|
||||||
|
Format: FormatJPEG,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: FitCover,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := proc.Process(ctx, bytes.NewReader(input), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Process() error = %v", err)
|
||||||
|
}
|
||||||
|
defer result.Content.Close()
|
||||||
|
|
||||||
|
if result.Width != 640 {
|
||||||
|
t.Errorf("Process() width = %d, want 640", result.Width)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Height != 480 {
|
||||||
|
t.Errorf("Process() height = %d, want 480", result.Height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImageProcessor_FitContain(t *testing.T) {
|
||||||
|
proc := NewImageProcessor()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// 800x400 image (2:1 aspect) into 400x400 box with contain
|
||||||
|
// Should result in 400x200 (maintaining aspect ratio)
|
||||||
|
input := createTestJPEG(t, 800, 400)
|
||||||
|
|
||||||
|
req := &ImageRequest{
|
||||||
|
Size: Size{Width: 400, Height: 400},
|
||||||
|
Format: FormatJPEG,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: FitContain,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := proc.Process(ctx, bytes.NewReader(input), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Process() error = %v", err)
|
||||||
|
}
|
||||||
|
defer result.Content.Close()
|
||||||
|
|
||||||
|
// With contain, the image should fit within the box
|
||||||
|
if result.Width > 400 || result.Height > 400 {
|
||||||
|
t.Errorf("Process() size %dx%d exceeds 400x400 box", result.Width, result.Height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImageProcessor_ProcessPNG(t *testing.T) {
|
||||||
|
proc := NewImageProcessor()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
input := createTestPNG(t, 400, 300)
|
||||||
|
|
||||||
|
req := &ImageRequest{
|
||||||
|
Size: Size{Width: 200, Height: 150},
|
||||||
|
Format: FormatPNG,
|
||||||
|
FitMode: FitCover,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := proc.Process(ctx, bytes.NewReader(input), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Process() error = %v", err)
|
||||||
|
}
|
||||||
|
defer result.Content.Close()
|
||||||
|
|
||||||
|
if result.Width != 200 {
|
||||||
|
t.Errorf("Process() width = %d, want 200", result.Width)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Height != 150 {
|
||||||
|
t.Errorf("Process() height = %d, want 150", result.Height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImageProcessor_ImplementsInterface(t *testing.T) {
|
||||||
|
// Verify ImageProcessor implements Processor interface
|
||||||
|
var _ Processor = (*ImageProcessor)(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImageProcessor_SupportedFormats(t *testing.T) {
|
||||||
|
proc := NewImageProcessor()
|
||||||
|
|
||||||
|
inputFormats := proc.SupportedInputFormats()
|
||||||
|
if len(inputFormats) == 0 {
|
||||||
|
t.Error("SupportedInputFormats() returned empty slice")
|
||||||
|
}
|
||||||
|
|
||||||
|
outputFormats := proc.SupportedOutputFormats()
|
||||||
|
if len(outputFormats) == 0 {
|
||||||
|
t.Error("SupportedOutputFormats() returned empty slice")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user