From 5462c9222cbb78110ad5b38c1fc699bd25561b7f Mon Sep 17 00:00:00 2001 From: sneak Date: Thu, 8 Jan 2026 03:54:50 -0800 Subject: [PATCH] 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. --- go.mod | 6 +- go.sum | 21 ++- internal/imgcache/processor.go | 244 ++++++++++++++++++++++++++++ internal/imgcache/processor_test.go | 242 +++++++++++++++++++++++++++ 4 files changed, 503 insertions(+), 10 deletions(-) create mode 100644 internal/imgcache/processor.go create mode 100644 internal/imgcache/processor_test.go diff --git a/go.mod b/go.mod index fe5bba3..7a8f529 100644 --- a/go.mod +++ b/go.mod @@ -5,12 +5,14 @@ go 1.25.4 require ( git.eeqj.de/sneak/smartconfig v1.0.0 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/go-chi/chi/v5 v5.2.3 github.com/go-chi/cors v1.2.2 github.com/prometheus/client_golang v1.23.2 github.com/slok/go-http-metrics v0.13.0 go.uber.org/fx v1.24.0 + golang.org/x/image v0.34.0 modernc.org/sqlite v1.42.2 ) @@ -124,10 +126,10 @@ require ( golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/net v0.43.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/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 google.golang.org/api v0.237.0 // indirect google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2 // indirect diff --git a/go.sum b/go.sum index df65e0c..be40c98 100644 --- a/go.sum +++ b/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/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/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/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 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/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/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.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= -golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= +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-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 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-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.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +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-20180905080454-ebe1bf3edb33/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.3/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.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +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/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 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-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.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= -golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= +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-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/imgcache/processor.go b/internal/imgcache/processor.go new file mode 100644 index 0000000..7ce8721 --- /dev/null +++ b/internal/imgcache/processor.go @@ -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 + } +} diff --git a/internal/imgcache/processor_test.go b/internal/imgcache/processor_test.go new file mode 100644 index 0000000..209fd93 --- /dev/null +++ b/internal/imgcache/processor_test.go @@ -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") + } +}