snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
This commit is contained in:
231
server/channels/app/imaging/decode.go
Normal file
231
server/channels/app/imaging/decode.go
Normal file
@@ -0,0 +1,231 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package imaging
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"sync"
|
||||
|
||||
_ "golang.org/x/image/bmp"
|
||||
_ "golang.org/x/image/tiff"
|
||||
_ "golang.org/x/image/webp"
|
||||
)
|
||||
|
||||
// DecoderOptions holds configuration options for an image decoder.
|
||||
type DecoderOptions struct {
|
||||
// The level of concurrency for the decoder. This defines a limit on the
|
||||
// number of concurrently running encoding goroutines.
|
||||
ConcurrencyLevel int
|
||||
|
||||
// MaxDecodedResolution, when greater than zero, is the maximum number of
|
||||
// pixels (width*height) an image may declare before it is decoded. Images
|
||||
// exceeding this limit are rejected up front. This is a defense-in-depth
|
||||
// guard against decompression bombs that bounds server-side memory
|
||||
// allocation regardless of the underlying codec's behavior.
|
||||
MaxDecodedResolution int64
|
||||
}
|
||||
|
||||
func (o *DecoderOptions) validate() error {
|
||||
if o.ConcurrencyLevel < 0 {
|
||||
return errors.New("ConcurrencyLevel must be non-negative")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Decoder holds the necessary state to decode images.
|
||||
// This is safe to be used from multiple goroutines.
|
||||
type Decoder struct {
|
||||
sem chan struct{}
|
||||
opts DecoderOptions
|
||||
}
|
||||
|
||||
// NewDecoder creates and returns a new image decoder with the given options.
|
||||
func NewDecoder(opts DecoderOptions) (*Decoder, error) {
|
||||
var d Decoder
|
||||
if err := opts.validate(); err != nil {
|
||||
return nil, fmt.Errorf("imaging: error validating decoder options: %w", err)
|
||||
}
|
||||
if opts.ConcurrencyLevel > 0 {
|
||||
d.sem = make(chan struct{}, opts.ConcurrencyLevel)
|
||||
}
|
||||
d.opts = opts
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
// enforceResolutionLimit inspects the image header and rejects images whose
|
||||
// declared resolution exceeds the configured MaxDecodedResolution before any
|
||||
// pixel data is decoded. It returns the reader to use for the subsequent full
|
||||
// decode: seekable readers are rewound to their original position, while
|
||||
// non-seekable readers are buffered so the cap is enforced for every input.
|
||||
func (d *Decoder) enforceResolutionLimit(rd io.Reader) (io.Reader, error) {
|
||||
if d.opts.MaxDecodedResolution <= 0 {
|
||||
return rd, nil
|
||||
}
|
||||
|
||||
if seeker, ok := rd.(io.ReadSeeker); ok {
|
||||
// Preserve the caller's position so an image decoded from a non-zero
|
||||
// offset still lines up for the full decode.
|
||||
start, err := seeker.Seek(0, io.SeekCurrent)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("imaging: failed to read image position: %w", err)
|
||||
}
|
||||
cfg, _, cfgErr := image.DecodeConfig(seeker)
|
||||
if _, err := seeker.Seek(start, io.SeekStart); err != nil {
|
||||
return nil, fmt.Errorf("imaging: failed to seek after reading image config: %w", err)
|
||||
}
|
||||
if err := d.checkConfigResolution(cfg, cfgErr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rd, nil
|
||||
}
|
||||
|
||||
// Non-seekable reader: buffer the input so the resolution cap can still be
|
||||
// enforced and the data can be decoded afterwards.
|
||||
data, err := io.ReadAll(rd)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("imaging: failed to read image data: %w", err)
|
||||
}
|
||||
cfg, _, cfgErr := image.DecodeConfig(bytes.NewReader(data))
|
||||
if err := d.checkConfigResolution(cfg, cfgErr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bytes.NewReader(data), nil
|
||||
}
|
||||
|
||||
// checkConfigResolution rejects a decoded image config whose resolution exceeds
|
||||
// the configured cap. A config-decode error is ignored so the subsequent full
|
||||
// decode can surface a meaningful error for malformed input.
|
||||
func (d *Decoder) checkConfigResolution(cfg image.Config, cfgErr error) error {
|
||||
if cfgErr != nil {
|
||||
return nil
|
||||
}
|
||||
if exceedsResolution(int64(cfg.Width), int64(cfg.Height), d.opts.MaxDecodedResolution) {
|
||||
return fmt.Errorf("imaging: image resolution %dx%d exceeds the maximum allowed %d pixels", cfg.Width, cfg.Height, d.opts.MaxDecodedResolution)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// exceedsResolution reports whether width*height exceeds maxRes. It divides
|
||||
// instead of multiplying so it can't overflow int64 for very large declared
|
||||
// dimensions.
|
||||
func exceedsResolution(width, height, maxRes int64) bool {
|
||||
if width <= 0 || height <= 0 {
|
||||
return false
|
||||
}
|
||||
return width > maxRes/height
|
||||
}
|
||||
|
||||
// Decode decodes the given encoded data and returns the decoded image.
|
||||
func (d *Decoder) Decode(rd io.Reader) (img image.Image, format string, err error) {
|
||||
rd, err = d.enforceResolutionLimit(rd)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
if d.opts.ConcurrencyLevel != 0 {
|
||||
d.sem <- struct{}{}
|
||||
defer func() { <-d.sem }()
|
||||
}
|
||||
|
||||
img, format, err = image.Decode(rd)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("imaging: failed to decode image: %w", err)
|
||||
}
|
||||
|
||||
return img, format, nil
|
||||
}
|
||||
|
||||
// DecodeMemBounded works similarly to Decode but also returns a release function that
|
||||
// must be called when access to the raw image is not needed anymore.
|
||||
// This sets the raw image data pointer to nil in an attempt to help the GC to re-use the underlying data as soon as possible.
|
||||
func (d *Decoder) DecodeMemBounded(rd io.Reader) (img image.Image, format string, releaseFunc func(), err error) {
|
||||
rd, err = d.enforceResolutionLimit(rd)
|
||||
if err != nil {
|
||||
return nil, "", nil, err
|
||||
}
|
||||
|
||||
if d.opts.ConcurrencyLevel != 0 {
|
||||
d.sem <- struct{}{}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
<-d.sem
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
img, format, err = image.Decode(rd)
|
||||
if err != nil {
|
||||
return nil, "", nil, fmt.Errorf("imaging: failed to decode image: %w", err)
|
||||
}
|
||||
|
||||
var once sync.Once
|
||||
releaseFunc = func() {
|
||||
if d.opts.ConcurrencyLevel == 0 {
|
||||
return
|
||||
}
|
||||
once.Do(func() {
|
||||
if img != nil {
|
||||
releaseImageData(img)
|
||||
}
|
||||
<-d.sem
|
||||
})
|
||||
}
|
||||
|
||||
return img, format, releaseFunc, nil
|
||||
}
|
||||
|
||||
// DecodeConfig returns the image config for the given data.
|
||||
func (d *Decoder) DecodeConfig(rd io.Reader) (image.Config, string, error) {
|
||||
img, format, err := image.DecodeConfig(rd)
|
||||
if err != nil {
|
||||
return image.Config{}, "", fmt.Errorf("imaging: failed to decode image config: %w", err)
|
||||
}
|
||||
return img, format, nil
|
||||
}
|
||||
|
||||
// GetDimensions returns the dimensions for the given encoded image data.
|
||||
func GetDimensions(imageData io.Reader) (width int, height int, err error) {
|
||||
cfg, _, err := image.DecodeConfig(imageData)
|
||||
width, height = cfg.Width, cfg.Height
|
||||
if seeker, ok := imageData.(io.Seeker); ok {
|
||||
_, err2 := seeker.Seek(0, 0)
|
||||
if err == nil && err2 != nil {
|
||||
err = fmt.Errorf("failed to seek back to the beginning of the image data: %w", err2)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// This is only needed to try and simplify GC work.
|
||||
func releaseImageData(img image.Image) {
|
||||
switch raw := img.(type) {
|
||||
case *image.Alpha:
|
||||
raw.Pix = nil
|
||||
case *image.Alpha16:
|
||||
raw.Pix = nil
|
||||
case *image.Gray:
|
||||
raw.Pix = nil
|
||||
case *image.Gray16:
|
||||
raw.Pix = nil
|
||||
case *image.NRGBA:
|
||||
raw.Pix = nil
|
||||
case *image.NRGBA64:
|
||||
raw.Pix = nil
|
||||
case *image.Paletted:
|
||||
raw.Pix = nil
|
||||
case *image.RGBA:
|
||||
raw.Pix = nil
|
||||
case *image.RGBA64:
|
||||
raw.Pix = nil
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
87
server/channels/app/imaging/decode_bench_test.go
Normal file
87
server/channels/app/imaging/decode_bench_test.go
Normal file
@@ -0,0 +1,87 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package imaging
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/mattermost/server/v8/channels/utils/fileutils"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func BenchmarkDecoderDecode(b *testing.B) {
|
||||
n := runtime.NumCPU()
|
||||
for k := 1; k <= n; k++ {
|
||||
b.Run(fmt.Sprintf("%d concurrency", k), func(b *testing.B) {
|
||||
d, err := NewDecoder(DecoderOptions{
|
||||
ConcurrencyLevel: k,
|
||||
})
|
||||
require.NotNil(b, d)
|
||||
require.NoError(b, err)
|
||||
|
||||
imgDir, ok := fileutils.FindDir("tests")
|
||||
require.True(b, ok)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for b.Loop() {
|
||||
// Setup
|
||||
b.StopTimer()
|
||||
wg.Add(1)
|
||||
imgFile, err := os.Open(imgDir + "/fill_test_opaque.png")
|
||||
require.NoError(b, err)
|
||||
defer imgFile.Close()
|
||||
b.StartTimer()
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
img, _, err := d.Decode(imgFile)
|
||||
require.NoError(b, err)
|
||||
require.NotNil(b, img)
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkDecoderDecodeMemBounded(b *testing.B) {
|
||||
n := runtime.NumCPU()
|
||||
for k := 1; k <= n; k++ {
|
||||
b.Run(fmt.Sprintf("%d concurrency", k), func(b *testing.B) {
|
||||
d, err := NewDecoder(DecoderOptions{
|
||||
ConcurrencyLevel: k,
|
||||
})
|
||||
require.NotNil(b, d)
|
||||
require.NoError(b, err)
|
||||
|
||||
imgDir, ok := fileutils.FindDir("tests")
|
||||
require.True(b, ok)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for b.Loop() {
|
||||
b.StopTimer()
|
||||
wg.Add(1)
|
||||
imgFile, err := os.Open(imgDir + "/fill_test_opaque.png")
|
||||
require.NoError(b, err)
|
||||
defer imgFile.Close()
|
||||
b.StartTimer()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
img, _, release, err := d.DecodeMemBounded(imgFile)
|
||||
require.NoError(b, err)
|
||||
require.NotNil(b, img)
|
||||
release()
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
})
|
||||
}
|
||||
}
|
||||
345
server/channels/app/imaging/decode_test.go
Normal file
345
server/channels/app/imaging/decode_test.go
Normal file
@@ -0,0 +1,345 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package imaging
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/png"
|
||||
"io"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/mattermost/server/v8/channels/utils/fileutils"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewDecoder(t *testing.T) {
|
||||
t.Run("invalid options", func(t *testing.T) {
|
||||
d, err := NewDecoder(DecoderOptions{
|
||||
ConcurrencyLevel: -1,
|
||||
})
|
||||
require.Nil(t, d)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("empty options", func(t *testing.T) {
|
||||
d, err := NewDecoder(DecoderOptions{})
|
||||
require.NotNil(t, d)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, d.sem)
|
||||
})
|
||||
|
||||
t.Run("valid options", func(t *testing.T) {
|
||||
d, err := NewDecoder(DecoderOptions{
|
||||
ConcurrencyLevel: 4,
|
||||
})
|
||||
require.NotNil(t, d)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, d.sem)
|
||||
require.Equal(t, 4, cap(d.sem))
|
||||
})
|
||||
}
|
||||
|
||||
func TestDecoderDecode(t *testing.T) {
|
||||
t.Run("default options", func(t *testing.T) {
|
||||
d, err := NewDecoder(DecoderOptions{})
|
||||
require.NotNil(t, d)
|
||||
require.NoError(t, err)
|
||||
|
||||
imgDir, ok := fileutils.FindDir("tests")
|
||||
require.True(t, ok)
|
||||
|
||||
imgFile, err := os.Open(imgDir + "/test.png")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, imgFile)
|
||||
defer func() {
|
||||
require.NoError(t, imgFile.Close())
|
||||
}()
|
||||
|
||||
img, format, err := d.Decode(imgFile)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, img)
|
||||
require.Equal(t, "png", format)
|
||||
})
|
||||
|
||||
t.Run("concurrency bounded", func(t *testing.T) {
|
||||
d, err := NewDecoder(DecoderOptions{
|
||||
ConcurrencyLevel: 1,
|
||||
})
|
||||
require.NotNil(t, d)
|
||||
require.NoError(t, err)
|
||||
|
||||
imgDir, ok := fileutils.FindDir("tests")
|
||||
require.True(t, ok)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
imgFile, err := os.Open(imgDir + "/test.png")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, imgFile)
|
||||
|
||||
defer func() {
|
||||
require.NoError(t, imgFile.Close())
|
||||
}()
|
||||
|
||||
img, format, err := d.Decode(imgFile)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, img)
|
||||
require.Equal(t, "png", format)
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
imgFile, err := os.Open(imgDir + "/test.png")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, imgFile)
|
||||
|
||||
defer func() {
|
||||
require.NoError(t, imgFile.Close())
|
||||
}()
|
||||
|
||||
img, format, err := d.Decode(imgFile)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, img)
|
||||
require.Equal(t, "png", format)
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
require.Empty(t, d.sem)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPSDNotSupported(t *testing.T) {
|
||||
// MM-67077: PSD preview support was removed due to memory vulnerability in oov/psd package
|
||||
d, err := NewDecoder(DecoderOptions{})
|
||||
require.NotNil(t, d)
|
||||
require.NoError(t, err)
|
||||
|
||||
// PSD file header magic bytes: "8BPS" followed by version (0x0001 for PSD)
|
||||
psdHeader := []byte("8BPS\x00\x01")
|
||||
_, _, err = d.Decode(bytes.NewReader(psdHeader))
|
||||
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "unknown format")
|
||||
}
|
||||
|
||||
func TestDecoderDecodeMemBounded(t *testing.T) {
|
||||
t.Run("concurrency bounded", func(t *testing.T) {
|
||||
d, err := NewDecoder(DecoderOptions{
|
||||
ConcurrencyLevel: 1,
|
||||
})
|
||||
require.NotNil(t, d)
|
||||
require.NoError(t, err)
|
||||
|
||||
imgDir, ok := fileutils.FindDir("tests")
|
||||
require.True(t, ok)
|
||||
|
||||
imgFile, err := os.Open(imgDir + "/test.png")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, imgFile)
|
||||
|
||||
defer func() {
|
||||
require.NoError(t, imgFile.Close())
|
||||
}()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
|
||||
var lock sync.Mutex
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
img, format, release, err := d.DecodeMemBounded(imgFile)
|
||||
require.NoError(t, err)
|
||||
defer release()
|
||||
|
||||
lock.Lock()
|
||||
_, err = imgFile.Seek(0, 0)
|
||||
require.NoError(t, err)
|
||||
lock.Unlock()
|
||||
|
||||
require.NotNil(t, img)
|
||||
require.Equal(t, "png", format)
|
||||
require.NotNil(t, release)
|
||||
require.NotEmpty(t, d.sem)
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
img, format, release, err := d.DecodeMemBounded(imgFile)
|
||||
require.NoError(t, err)
|
||||
defer release()
|
||||
|
||||
lock.Lock()
|
||||
_, err = imgFile.Seek(0, 0)
|
||||
require.NoError(t, err)
|
||||
lock.Unlock()
|
||||
|
||||
require.NotNil(t, img)
|
||||
require.Equal(t, "png", format)
|
||||
require.NotNil(t, release)
|
||||
require.NotEmpty(t, d.sem)
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
require.Empty(t, d.sem)
|
||||
})
|
||||
|
||||
t.Run("decode error", func(t *testing.T) {
|
||||
d, err := NewDecoder(DecoderOptions{
|
||||
ConcurrencyLevel: 1,
|
||||
})
|
||||
require.NotNil(t, d)
|
||||
require.NoError(t, err)
|
||||
|
||||
var data bytes.Buffer
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
img, format, release, err := d.DecodeMemBounded(&data)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, img)
|
||||
require.Empty(t, format)
|
||||
require.Nil(t, release)
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
img, format, release, err := d.DecodeMemBounded(&data)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, img)
|
||||
require.Empty(t, format)
|
||||
require.Nil(t, release)
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
require.Empty(t, d.sem)
|
||||
})
|
||||
|
||||
t.Run("multiple releases", func(t *testing.T) {
|
||||
d, err := NewDecoder(DecoderOptions{
|
||||
ConcurrencyLevel: 1,
|
||||
})
|
||||
require.NotNil(t, d)
|
||||
require.NoError(t, err)
|
||||
|
||||
imgDir, ok := fileutils.FindDir("tests")
|
||||
require.True(t, ok)
|
||||
|
||||
imgFile, err := os.Open(imgDir + "/test.png")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, imgFile)
|
||||
defer func() {
|
||||
require.NoError(t, imgFile.Close())
|
||||
}()
|
||||
|
||||
img, format, release, err := d.DecodeMemBounded(imgFile)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, img)
|
||||
require.Equal(t, "png", format)
|
||||
require.NotNil(t, release)
|
||||
require.Len(t, d.sem, 1)
|
||||
release()
|
||||
require.Empty(t, d.sem)
|
||||
release()
|
||||
require.Empty(t, d.sem)
|
||||
release()
|
||||
require.Empty(t, d.sem)
|
||||
})
|
||||
}
|
||||
|
||||
// TestDecoderMaxDecodedResolution verifies the defense-in-depth cap: the shared
|
||||
// decoder refuses to decode any image whose declared resolution exceeds the
|
||||
// configured limit, regardless of the underlying codec, before allocating
|
||||
// pixel data.
|
||||
func TestDecoderMaxDecodedResolution(t *testing.T) {
|
||||
makePNG := func(w, h int) []byte {
|
||||
var buf bytes.Buffer
|
||||
require.NoError(t, png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, w, h))))
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
d, err := NewDecoder(DecoderOptions{MaxDecodedResolution: 100})
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("Decode rejects image exceeding the cap", func(t *testing.T) {
|
||||
img, format, decErr := d.Decode(bytes.NewReader(makePNG(50, 50))) // 2500px > 100
|
||||
require.Error(t, decErr)
|
||||
require.ErrorContains(t, decErr, "exceeds the maximum allowed")
|
||||
require.Nil(t, img)
|
||||
require.Empty(t, format)
|
||||
})
|
||||
|
||||
t.Run("Decode allows image within the cap", func(t *testing.T) {
|
||||
img, format, decErr := d.Decode(bytes.NewReader(makePNG(5, 5))) // 25px <= 100
|
||||
require.NoError(t, decErr)
|
||||
require.NotNil(t, img)
|
||||
require.Equal(t, "png", format)
|
||||
})
|
||||
|
||||
t.Run("DecodeMemBounded rejects image exceeding the cap", func(t *testing.T) {
|
||||
img, format, release, decErr := d.DecodeMemBounded(bytes.NewReader(makePNG(50, 50)))
|
||||
require.Error(t, decErr)
|
||||
require.ErrorContains(t, decErr, "exceeds the maximum allowed")
|
||||
require.Nil(t, img)
|
||||
require.Empty(t, format)
|
||||
require.Nil(t, release)
|
||||
})
|
||||
|
||||
t.Run("cap disabled by default", func(t *testing.T) {
|
||||
dd, ddErr := NewDecoder(DecoderOptions{})
|
||||
require.NoError(t, ddErr)
|
||||
img, _, decErr := dd.Decode(bytes.NewReader(makePNG(50, 50)))
|
||||
require.NoError(t, decErr)
|
||||
require.NotNil(t, img)
|
||||
})
|
||||
|
||||
// A non-seekable reader must still be subject to the cap; the decoder
|
||||
// buffers it internally rather than silently bypassing the check.
|
||||
t.Run("cap enforced on non-seekable reader", func(t *testing.T) {
|
||||
// io.MultiReader is not an io.ReadSeeker.
|
||||
img, format, decErr := d.Decode(io.MultiReader(bytes.NewReader(makePNG(50, 50))))
|
||||
require.Error(t, decErr)
|
||||
require.ErrorContains(t, decErr, "exceeds the maximum allowed")
|
||||
require.Nil(t, img)
|
||||
require.Empty(t, format)
|
||||
})
|
||||
|
||||
t.Run("non-seekable reader within cap decodes from buffer", func(t *testing.T) {
|
||||
img, format, decErr := d.Decode(io.MultiReader(bytes.NewReader(makePNG(5, 5))))
|
||||
require.NoError(t, decErr)
|
||||
require.NotNil(t, img)
|
||||
require.Equal(t, "png", format)
|
||||
})
|
||||
}
|
||||
|
||||
// TestExceedsResolution verifies the resolution comparison rejects over-limit
|
||||
// images (including dimensions large enough to overflow a naive int64
|
||||
// multiplication) without wrapping around.
|
||||
func TestExceedsResolution(t *testing.T) {
|
||||
const maxRes = int64(7680 * 4320) // default 8K cap, ~33 MPx
|
||||
|
||||
require.False(t, exceedsResolution(100, 100, maxRes))
|
||||
require.False(t, exceedsResolution(7680, 4320, maxRes)) // exactly at the cap
|
||||
require.True(t, exceedsResolution(10000, 10000, maxRes))
|
||||
|
||||
// width*height here (2^80) overflows int64; the division-based check must
|
||||
// still reject it rather than wrap to a small/negative value.
|
||||
require.True(t, exceedsResolution(1<<40, 1<<40, maxRes))
|
||||
|
||||
// Non-positive dimensions are treated as not exceeding the cap.
|
||||
require.False(t, exceedsResolution(0, 100, maxRes))
|
||||
require.False(t, exceedsResolution(100, 0, maxRes))
|
||||
}
|
||||
88
server/channels/app/imaging/encode.go
Normal file
88
server/channels/app/imaging/encode.go
Normal file
@@ -0,0 +1,88 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package imaging
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"io"
|
||||
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
)
|
||||
|
||||
// EncoderOptions holds configuration options for an image encoder.
|
||||
type EncoderOptions struct {
|
||||
// The level of concurrency for the encoder. This defines a limit on the
|
||||
// number of concurrently running encoding goroutines.
|
||||
ConcurrencyLevel int
|
||||
}
|
||||
|
||||
func (o *EncoderOptions) validate() error {
|
||||
if o.ConcurrencyLevel < 0 {
|
||||
return errors.New("ConcurrencyLevel must be non-negative")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Decoder holds the necessary state to encode images.
|
||||
// This is safe to be used from multiple goroutines.
|
||||
type Encoder struct {
|
||||
sem chan struct{}
|
||||
opts EncoderOptions
|
||||
pngEncoder *png.Encoder
|
||||
}
|
||||
|
||||
// NewEncoder creates and returns a new image encoder with the given options.
|
||||
func NewEncoder(opts EncoderOptions) (*Encoder, error) {
|
||||
var e Encoder
|
||||
if err := opts.validate(); err != nil {
|
||||
return nil, fmt.Errorf("imaging: error validating encoder options: %w", err)
|
||||
}
|
||||
if opts.ConcurrencyLevel > 0 {
|
||||
e.sem = make(chan struct{}, opts.ConcurrencyLevel)
|
||||
}
|
||||
e.opts = opts
|
||||
e.pngEncoder = &png.Encoder{
|
||||
CompressionLevel: png.BestCompression,
|
||||
}
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
// EncodeJPEG encodes the given image in JPEG format and writes the data to
|
||||
// the passed writer.
|
||||
func (e *Encoder) EncodeJPEG(wr io.Writer, img image.Image, quality int) error {
|
||||
if e.opts.ConcurrencyLevel > 0 {
|
||||
e.sem <- struct{}{}
|
||||
defer func() {
|
||||
<-e.sem
|
||||
}()
|
||||
}
|
||||
|
||||
var encOpts jpeg.Options
|
||||
encOpts.Quality = quality
|
||||
if err := jpeg.Encode(wr, img, &encOpts); err != nil {
|
||||
return fmt.Errorf("imaging: failed to encode jpeg: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// EncodePNG encodes the given image in PNG format and writes the data to
|
||||
// the passed writer.
|
||||
func (e *Encoder) EncodePNG(wr io.Writer, img image.Image) error {
|
||||
if e.opts.ConcurrencyLevel > 0 {
|
||||
e.sem <- struct{}{}
|
||||
defer func() {
|
||||
<-e.sem
|
||||
}()
|
||||
}
|
||||
|
||||
if err := e.pngEncoder.Encode(wr, img); err != nil {
|
||||
return fmt.Errorf("imaging: failed to encode png: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
90
server/channels/app/imaging/encode_test.go
Normal file
90
server/channels/app/imaging/encode_test.go
Normal file
@@ -0,0 +1,90 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package imaging
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewEncoder(t *testing.T) {
|
||||
t.Run("invalid options", func(t *testing.T) {
|
||||
e, err := NewEncoder(EncoderOptions{
|
||||
ConcurrencyLevel: -1,
|
||||
})
|
||||
require.Nil(t, e)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("empty options", func(t *testing.T) {
|
||||
e, err := NewEncoder(EncoderOptions{})
|
||||
require.NotNil(t, e)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, e.sem)
|
||||
})
|
||||
|
||||
t.Run("valid options", func(t *testing.T) {
|
||||
e, err := NewEncoder(EncoderOptions{
|
||||
ConcurrencyLevel: 4,
|
||||
})
|
||||
require.NotNil(t, e)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, e.sem)
|
||||
require.Equal(t, 4, cap(e.sem))
|
||||
})
|
||||
}
|
||||
|
||||
func TestEncoderEncode(t *testing.T) {
|
||||
t.Run("default options", func(t *testing.T) {
|
||||
e, err := NewEncoder(EncoderOptions{})
|
||||
require.NotNil(t, e)
|
||||
require.NoError(t, err)
|
||||
|
||||
var buf bytes.Buffer
|
||||
rawImg := image.NewRGBA(image.Rect(0, 0, 1280, 1024))
|
||||
err = e.EncodePNG(&buf, rawImg)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, buf)
|
||||
|
||||
err = e.EncodeJPEG(&buf, rawImg, 50)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, buf)
|
||||
})
|
||||
|
||||
t.Run("concurrency bounded", func(t *testing.T) {
|
||||
e, err := NewEncoder(EncoderOptions{
|
||||
ConcurrencyLevel: 1,
|
||||
})
|
||||
require.NotNil(t, e)
|
||||
require.NoError(t, err)
|
||||
|
||||
rawImg := image.NewRGBA(image.Rect(0, 0, 1280, 1024))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
var buf bytes.Buffer
|
||||
err := e.EncodePNG(&buf, rawImg)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, buf)
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
var buf bytes.Buffer
|
||||
err := e.EncodeJPEG(&buf, rawImg, 50)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, buf)
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
require.Empty(t, e.sem)
|
||||
})
|
||||
}
|
||||
184
server/channels/app/imaging/orientation.go
Normal file
184
server/channels/app/imaging/orientation.go
Normal file
@@ -0,0 +1,184 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package imaging
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"io"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/bep/imagemeta"
|
||||
"github.com/boxes-ltd/imaging"
|
||||
)
|
||||
|
||||
const (
|
||||
/*
|
||||
EXIF Image Orientations
|
||||
1 2 3 4 5 6 7 8
|
||||
|
||||
888888 888888 88 88 8888888888 88 88 8888888888
|
||||
88 88 88 88 88 88 88 88 88 88 88 88
|
||||
8888 8888 8888 8888 88 8888888888 8888888888 88
|
||||
88 88 88 88
|
||||
88 88 888888 888888
|
||||
*/
|
||||
Upright = iota + 1
|
||||
UprightMirrored
|
||||
UpsideDown
|
||||
UpsideDownMirrored
|
||||
RotatedCWMirrored
|
||||
RotatedCCW
|
||||
RotatedCCWMirrored
|
||||
RotatedCW
|
||||
)
|
||||
|
||||
// MakeImageUpright changes the orientation of the given image.
|
||||
func MakeImageUpright(img image.Image, orientation int) image.Image {
|
||||
switch orientation {
|
||||
case UprightMirrored:
|
||||
return imaging.FlipH(img)
|
||||
case UpsideDown:
|
||||
return imaging.Rotate180(img)
|
||||
case UpsideDownMirrored:
|
||||
return imaging.FlipV(img)
|
||||
case RotatedCWMirrored:
|
||||
return imaging.Transpose(img)
|
||||
case RotatedCCW:
|
||||
return imaging.Rotate270(img)
|
||||
case RotatedCCWMirrored:
|
||||
return imaging.Transverse(img)
|
||||
case RotatedCW:
|
||||
return imaging.Rotate90(img)
|
||||
default:
|
||||
return img
|
||||
}
|
||||
}
|
||||
|
||||
// bufReadSeeker wraps an io.Reader, buffering reads to support backward seeks
|
||||
// required by imagemeta's EXIF offset-based tag value decoding.
|
||||
type bufReadSeeker struct {
|
||||
r io.Reader
|
||||
buf []byte
|
||||
pos int64
|
||||
}
|
||||
|
||||
// maxExifScanSize prevents the buffer from growing over a reasonable limit;
|
||||
// EXIF data lives near the file start in all common formats.
|
||||
const maxExifScanSize = 10 * 1024 * 1024
|
||||
|
||||
func (b *bufReadSeeker) Read(p []byte) (int, error) {
|
||||
if b.pos < int64(len(b.buf)) {
|
||||
n := copy(p, b.buf[b.pos:])
|
||||
b.pos += int64(n)
|
||||
return n, nil
|
||||
}
|
||||
remaining := maxExifScanSize - int64(len(b.buf))
|
||||
if remaining <= 0 {
|
||||
return 0, fmt.Errorf("read exceeded %d-byte scan limit", maxExifScanSize)
|
||||
}
|
||||
if int64(len(p)) > remaining {
|
||||
p = p[:remaining]
|
||||
}
|
||||
n, err := b.r.Read(p)
|
||||
if n > 0 {
|
||||
b.buf = append(b.buf, p[:n]...)
|
||||
b.pos += int64(n)
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (b *bufReadSeeker) Seek(offset int64, whence int) (int64, error) {
|
||||
var newPos int64
|
||||
switch whence {
|
||||
case io.SeekStart:
|
||||
newPos = offset
|
||||
case io.SeekCurrent:
|
||||
newPos = b.pos + offset
|
||||
default:
|
||||
return b.pos, fmt.Errorf("seek: unsupported whence %d", whence)
|
||||
}
|
||||
if newPos < 0 {
|
||||
return b.pos, fmt.Errorf("seek: negative position %d", newPos)
|
||||
}
|
||||
if newPos <= int64(len(b.buf)) {
|
||||
b.pos = newPos
|
||||
return b.pos, nil
|
||||
}
|
||||
if newPos > maxExifScanSize {
|
||||
return b.pos, fmt.Errorf("seek: target %d exceeds %d-byte scan limit", newPos, maxExifScanSize)
|
||||
}
|
||||
// Seek forward past buffered data: read ahead into the buffer in-place.
|
||||
toRead := int(newPos - int64(len(b.buf)))
|
||||
oldLen := len(b.buf)
|
||||
b.buf = slices.Grow(b.buf, toRead)[:oldLen+toRead]
|
||||
n, err := io.ReadFull(b.r, b.buf[oldLen:])
|
||||
b.buf = b.buf[:oldLen+n]
|
||||
b.pos = int64(len(b.buf))
|
||||
if errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
return b.pos, io.EOF
|
||||
}
|
||||
return b.pos, err
|
||||
}
|
||||
|
||||
// GetImageOrientation reads the input data and returns the EXIF encoded
|
||||
// image orientation. Supported formats are JPEG, PNG, TIFF, and WebP.
|
||||
// format accepts both bare names ("jpeg") and MIME-type strings ("image/jpeg").
|
||||
// Passing an io.ReadSeeker is preferable; plain io.Readers are wrapped in an
|
||||
// internal buffered seeker to support backward seeking required by some formats.
|
||||
func GetImageOrientation(input io.Reader, format string) (int, error) {
|
||||
orientation := Upright
|
||||
|
||||
// Strip the "image/" prefix from the format in case it's a MIME type.
|
||||
format, _ = strings.CutPrefix(format, "image/")
|
||||
|
||||
var imgFormat imagemeta.ImageFormat
|
||||
switch format {
|
||||
case "jpeg":
|
||||
imgFormat = imagemeta.JPEG
|
||||
case "png":
|
||||
imgFormat = imagemeta.PNG
|
||||
case "tiff":
|
||||
imgFormat = imagemeta.TIFF
|
||||
case "webp":
|
||||
imgFormat = imagemeta.WebP
|
||||
default:
|
||||
// We don't support EXIF on any other format.
|
||||
return orientation, fmt.Errorf("unsupported image format: %s", format)
|
||||
}
|
||||
|
||||
var rs io.ReadSeeker
|
||||
if r, ok := input.(io.ReadSeeker); ok {
|
||||
rs = r
|
||||
} else {
|
||||
rs = &bufReadSeeker{r: input}
|
||||
}
|
||||
|
||||
opts := imagemeta.Options{
|
||||
R: rs,
|
||||
HandleTag: func(tag imagemeta.TagInfo) error {
|
||||
if tag.Tag == "Orientation" {
|
||||
if o, ok := tag.Value.(uint16); ok {
|
||||
orientation = int(o)
|
||||
return imagemeta.ErrStopWalking
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
ShouldHandleTag: func(tag imagemeta.TagInfo) bool {
|
||||
// We only care about the orientation tag.
|
||||
return tag.Tag == "Orientation"
|
||||
},
|
||||
Sources: imagemeta.EXIF, // We only care about EXIF data.
|
||||
ImageFormat: imgFormat,
|
||||
}
|
||||
|
||||
if _, err := imagemeta.Decode(opts); err != nil {
|
||||
return Upright, fmt.Errorf("failed to decode exif data: %w", err)
|
||||
}
|
||||
|
||||
return orientation, nil
|
||||
}
|
||||
313
server/channels/app/imaging/orientation_test.go
Normal file
313
server/channels/app/imaging/orientation_test.go
Normal file
@@ -0,0 +1,313 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package imaging
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/mattermost/server/v8/channels/utils/fileutils"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBufReadSeeker(t *testing.T) {
|
||||
t.Run("Read", func(t *testing.T) {
|
||||
data := []byte("hello world")
|
||||
reader := bytes.NewReader(data)
|
||||
seeker := &bufReadSeeker{r: reader}
|
||||
|
||||
buf := make([]byte, 5)
|
||||
n, err := seeker.Read(buf)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 5, n)
|
||||
require.Equal(t, []byte("hello"), buf)
|
||||
|
||||
buf = make([]byte, 6)
|
||||
n, err = seeker.Read(buf)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 6, n)
|
||||
require.Equal(t, []byte(" world"), buf)
|
||||
|
||||
buf = make([]byte, 1)
|
||||
n, err = seeker.Read(buf)
|
||||
require.Equal(t, 0, n)
|
||||
require.ErrorIs(t, err, io.EOF)
|
||||
})
|
||||
|
||||
t.Run("Seek forward from start", func(t *testing.T) {
|
||||
data := []byte("hello world")
|
||||
reader := bytes.NewReader(data)
|
||||
seeker := &bufReadSeeker{r: reader}
|
||||
|
||||
pos, err := seeker.Seek(6, io.SeekStart)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(6), pos)
|
||||
|
||||
pos, err = seeker.Seek(6, io.SeekStart)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(6), pos)
|
||||
|
||||
pos, err = seeker.Seek(7, io.SeekStart)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(7), pos)
|
||||
|
||||
// Seeking backwards within the buffer is supported.
|
||||
pos, err = seeker.Seek(6, io.SeekStart)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(6), pos)
|
||||
|
||||
buf := make([]byte, 5)
|
||||
n, err := io.ReadFull(seeker, buf)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 5, n)
|
||||
require.Equal(t, []byte("world"), buf)
|
||||
})
|
||||
|
||||
t.Run("Seek forward from current", func(t *testing.T) {
|
||||
data := []byte("hello world")
|
||||
reader := bytes.NewReader(data)
|
||||
seeker := &bufReadSeeker{r: reader}
|
||||
|
||||
buf := make([]byte, 6)
|
||||
n, err := seeker.Read(buf)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 6, n)
|
||||
require.Equal(t, []byte("hello "), buf)
|
||||
|
||||
pos, err := seeker.Seek(2, io.SeekCurrent)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(8), pos)
|
||||
|
||||
buf = make([]byte, 3)
|
||||
n, err = seeker.Read(buf)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 3, n)
|
||||
require.Equal(t, []byte("rld"), buf)
|
||||
})
|
||||
|
||||
t.Run("Seek invalid cases", func(t *testing.T) {
|
||||
data := []byte("hello world")
|
||||
reader := bytes.NewReader(data)
|
||||
seeker := &bufReadSeeker{r: reader}
|
||||
|
||||
pos, err := seeker.Seek(-1, io.SeekCurrent)
|
||||
require.EqualError(t, err, "seek: negative position -1")
|
||||
require.Equal(t, int64(0), pos)
|
||||
|
||||
pos, err = seeker.Seek(0, io.SeekEnd)
|
||||
require.EqualError(t, err, "seek: unsupported whence 2")
|
||||
require.Equal(t, int64(0), pos)
|
||||
})
|
||||
|
||||
t.Run("Seek backward to start and re-read", func(t *testing.T) {
|
||||
data := []byte("hello world")
|
||||
seeker := &bufReadSeeker{r: bytes.NewReader(data)}
|
||||
|
||||
buf := make([]byte, len(data))
|
||||
_, err := io.ReadFull(seeker, buf)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, data, buf)
|
||||
|
||||
pos, err := seeker.Seek(0, io.SeekStart)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(0), pos)
|
||||
|
||||
buf2 := make([]byte, len(data))
|
||||
_, err = io.ReadFull(seeker, buf2)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, data, buf2)
|
||||
})
|
||||
|
||||
t.Run("Seek beyond EOF", func(t *testing.T) {
|
||||
data := []byte("hello")
|
||||
reader := bytes.NewReader(data)
|
||||
seeker := &bufReadSeeker{r: reader}
|
||||
|
||||
n, err := seeker.Seek(10, io.SeekStart)
|
||||
require.ErrorIs(t, err, io.EOF)
|
||||
require.Equal(t, int64(5), n)
|
||||
})
|
||||
|
||||
t.Run("Read enforces scan limit", func(t *testing.T) {
|
||||
seeker := &bufReadSeeker{
|
||||
r: bytes.NewReader([]byte("x")),
|
||||
buf: make([]byte, maxExifScanSize),
|
||||
pos: maxExifScanSize,
|
||||
}
|
||||
_, err := seeker.Read(make([]byte, 1))
|
||||
require.EqualError(t, err, fmt.Sprintf("read exceeded %d-byte scan limit", maxExifScanSize))
|
||||
})
|
||||
|
||||
t.Run("Read clamps large p to avoid overshooting scan limit", func(t *testing.T) {
|
||||
seeker := &bufReadSeeker{
|
||||
r: bytes.NewReader([]byte("AB")),
|
||||
buf: make([]byte, maxExifScanSize-1),
|
||||
pos: maxExifScanSize - 1,
|
||||
}
|
||||
p := make([]byte, 4096)
|
||||
n, err := seeker.Read(p)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, n)
|
||||
require.Equal(t, byte('A'), p[0])
|
||||
require.Equal(t, int64(maxExifScanSize), int64(len(seeker.buf)))
|
||||
})
|
||||
|
||||
t.Run("Seek enforces scan limit", func(t *testing.T) {
|
||||
seeker := &bufReadSeeker{r: bytes.NewReader(nil)}
|
||||
pos, err := seeker.Seek(maxExifScanSize+1, io.SeekStart)
|
||||
require.EqualError(t, err, fmt.Sprintf("seek: target %d exceeds %d-byte scan limit", maxExifScanSize+1, maxExifScanSize))
|
||||
require.Equal(t, int64(0), pos)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetImageOrientation(t *testing.T) {
|
||||
imgDir, ok := fileutils.FindDir("tests/exif_samples")
|
||||
require.True(t, ok, "Failed to find exif samples directory")
|
||||
|
||||
// Define orientations and their corresponding file prefixes
|
||||
orientations := map[string]int{
|
||||
"up": Upright,
|
||||
"up-mirrored": UprightMirrored,
|
||||
"down": UpsideDown,
|
||||
"down-mirrored": UpsideDownMirrored,
|
||||
"left": RotatedCCW,
|
||||
"left-mirrored": RotatedCWMirrored,
|
||||
"right": RotatedCW,
|
||||
"right-mirrored": RotatedCCWMirrored,
|
||||
}
|
||||
|
||||
// Define supported formats
|
||||
formats := []string{"jpg", "png", "tiff", "webp"}
|
||||
|
||||
// Generate test cases for all combinations
|
||||
var testCases []struct {
|
||||
name string
|
||||
fileName string
|
||||
expectedOrientation int
|
||||
}
|
||||
|
||||
for prefix, orientation := range orientations {
|
||||
for _, format := range formats {
|
||||
testCases = append(testCases, struct {
|
||||
name string
|
||||
fileName string
|
||||
expectedOrientation int
|
||||
}{
|
||||
name: fmt.Sprintf("%s (%s)", prefix, format),
|
||||
fileName: fmt.Sprintf("%s.%s", prefix, format),
|
||||
expectedOrientation: orientation,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
dec, err := NewDecoder(DecoderOptions{})
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, tc := range testCases {
|
||||
var orientation int
|
||||
imgPath := filepath.Join(imgDir, tc.fileName)
|
||||
file, err := os.Open(imgPath)
|
||||
require.NoError(t, err)
|
||||
defer file.Close()
|
||||
|
||||
_, format, err := dec.DecodeConfig(file)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run(tc.name+"_file", func(t *testing.T) {
|
||||
_, err = file.Seek(0, io.SeekStart)
|
||||
require.NoError(t, err)
|
||||
|
||||
orientation, err = GetImageOrientation(file, format)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.expectedOrientation, orientation, "Incorrect orientation detected for %s", tc.fileName)
|
||||
})
|
||||
|
||||
t.Run(tc.name+"_reader", func(t *testing.T) {
|
||||
_, err = file.Seek(0, io.SeekStart)
|
||||
require.NoError(t, err)
|
||||
|
||||
orientation, err = GetImageOrientation(&io.LimitedReader{R: file, N: 1024 * 1024}, format)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.expectedOrientation, orientation, "Incorrect orientation detected for %s", tc.fileName)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetImageOrientationEdgeCases(t *testing.T) {
|
||||
imgDir, ok := fileutils.FindDir("tests/exif_samples")
|
||||
require.True(t, ok, "Failed to find exif samples directory")
|
||||
|
||||
t.Run("MIME type format string", func(t *testing.T) {
|
||||
file, err := os.Open(filepath.Join(imgDir, "up.jpg"))
|
||||
require.NoError(t, err)
|
||||
defer file.Close()
|
||||
|
||||
orientation, err := GetImageOrientation(file, "image/jpeg")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, Upright, orientation)
|
||||
})
|
||||
|
||||
t.Run("unsupported format returns error", func(t *testing.T) {
|
||||
_, err := GetImageOrientation(bytes.NewReader([]byte("data")), "gif")
|
||||
require.EqualError(t, err, "unsupported image format: gif")
|
||||
})
|
||||
}
|
||||
|
||||
func TestMakeImageUpright(t *testing.T) {
|
||||
// Each case loads the canonical EXIF fixture for orientation N (the
|
||||
// 128x128 quadrants pattern in its stored, uncorrected form), applies
|
||||
// MakeImageUpright(., N), and asserts that the result has the same
|
||||
// pixels as the upright reference.
|
||||
tcs := []struct {
|
||||
name string
|
||||
orientation int
|
||||
inputName string
|
||||
}{
|
||||
{"Upright (no-op)", Upright, "quadrants-orientation-1.png"},
|
||||
{"UprightMirrored (FlipH)", UprightMirrored, "quadrants-orientation-2.png"},
|
||||
{"UpsideDown (Rotate180)", UpsideDown, "quadrants-orientation-3.png"},
|
||||
{"UpsideDownMirrored (FlipV)", UpsideDownMirrored, "quadrants-orientation-4.png"},
|
||||
{"RotatedCWMirrored (Transpose)", RotatedCWMirrored, "quadrants-orientation-5.png"},
|
||||
{"RotatedCCW (Rotate270)", RotatedCCW, "quadrants-orientation-6.png"},
|
||||
{"RotatedCCWMirrored (Transverse)", RotatedCCWMirrored, "quadrants-orientation-7.png"},
|
||||
{"RotatedCW (Rotate90)", RotatedCW, "quadrants-orientation-8.png"},
|
||||
// Unsupported orientations fall through to the default branch and
|
||||
// return the input unchanged. Pass the upright fixture so the
|
||||
// no-op result still equals the upright reference.
|
||||
{"unsupported orientation", 99, "quadrants-orientation-1.png"},
|
||||
}
|
||||
|
||||
imgDir, ok := fileutils.FindDir("tests/exif_samples")
|
||||
require.True(t, ok)
|
||||
|
||||
d, err := NewDecoder(DecoderOptions{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, d)
|
||||
|
||||
uprightFile, err := os.Open(filepath.Join(imgDir, "quadrants-orientation-1.png"))
|
||||
require.NoError(t, err)
|
||||
defer uprightFile.Close()
|
||||
|
||||
uprightImg, format, err := d.Decode(uprightFile)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "png", format)
|
||||
|
||||
for _, tc := range tcs {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
inputFile, err := os.Open(filepath.Join(imgDir, tc.inputName))
|
||||
require.NoError(t, err)
|
||||
defer inputFile.Close()
|
||||
|
||||
inputImg, format, err := d.Decode(inputFile)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "png", format)
|
||||
|
||||
requireSameImage(t, uprightImg, MakeImageUpright(inputImg, tc.orientation))
|
||||
})
|
||||
}
|
||||
}
|
||||
48
server/channels/app/imaging/preview.go
Normal file
48
server/channels/app/imaging/preview.go
Normal file
@@ -0,0 +1,48 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package imaging
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
|
||||
"github.com/boxes-ltd/imaging"
|
||||
)
|
||||
|
||||
// GeneratePreview generates the preview for the given image.
|
||||
func GeneratePreview(img image.Image, width int) image.Image {
|
||||
preview := img
|
||||
w := img.Bounds().Dx()
|
||||
|
||||
if w > width {
|
||||
preview = imaging.Resize(img, width, 0, imaging.Lanczos)
|
||||
}
|
||||
|
||||
return preview
|
||||
}
|
||||
|
||||
// GenerateThumbnail generates the thumbnail for the given image.
|
||||
func GenerateThumbnail(img image.Image, targetWidth, targetHeight int) image.Image {
|
||||
width := img.Bounds().Dx()
|
||||
height := img.Bounds().Dy()
|
||||
|
||||
// We keep aspect ratio and ensure the output dimensions are never higher than the provided targets.
|
||||
if width > height {
|
||||
return imaging.Resize(img, targetWidth, 0, imaging.Lanczos)
|
||||
}
|
||||
|
||||
return imaging.Resize(img, 0, targetHeight, imaging.Lanczos)
|
||||
}
|
||||
|
||||
// GenerateMiniPreviewImage generates the mini preview for the given image.
|
||||
func GenerateMiniPreviewImage(img image.Image, w, h, q int) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
preview := imaging.Resize(img, w, h, imaging.Lanczos)
|
||||
if err := jpeg.Encode(&buf, preview, &jpeg.Options{Quality: q}); err != nil {
|
||||
return nil, fmt.Errorf("failed to encode image to JPEG format: %w", err)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
124
server/channels/app/imaging/preview_bench_test.go
Normal file
124
server/channels/app/imaging/preview_bench_test.go
Normal file
@@ -0,0 +1,124 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package imaging
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newRGBAImage(w, h int) *image.RGBA {
|
||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
draw.Draw(img, img.Bounds(), image.NewUniform(color.RGBA{R: 100, G: 150, B: 200, A: 255}), image.Point{}, draw.Src)
|
||||
return img
|
||||
}
|
||||
|
||||
func BenchmarkGeneratePreview(b *testing.B) {
|
||||
cases := []struct {
|
||||
name string
|
||||
w, h int
|
||||
targetWidth int
|
||||
}{
|
||||
{"2000x1500 -> 1024", 2000, 1500, 1024},
|
||||
{"4000x3000 -> 1024", 4000, 3000, 1024},
|
||||
{"1024x768 -> 1024 (no-op)", 1024, 768, 1024},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
b.Run(tc.name, func(b *testing.B) {
|
||||
img := newRGBAImage(tc.w, tc.h)
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
GeneratePreview(img, tc.targetWidth)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkGenerateThumbnail(b *testing.B) {
|
||||
cases := []struct {
|
||||
name string
|
||||
w, h int
|
||||
targetW, targetH int
|
||||
}{
|
||||
{"2000x1500 landscape -> 120x100", 2000, 1500, 120, 100},
|
||||
{"1500x2000 portrait -> 120x100", 1500, 2000, 120, 100},
|
||||
{"4000x3000 landscape -> 120x100", 4000, 3000, 120, 100},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
b.Run(tc.name, func(b *testing.B) {
|
||||
img := newRGBAImage(tc.w, tc.h)
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
GenerateThumbnail(img, tc.targetW, tc.targetH)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkGenerateMiniPreviewImage(b *testing.B) {
|
||||
cases := []struct {
|
||||
name string
|
||||
w, h int
|
||||
targetW, targetH int
|
||||
quality int
|
||||
}{
|
||||
{"2000x1500 -> 120x100 q50", 2000, 1500, 120, 100, 50},
|
||||
{"4000x3000 -> 120x100 q50", 4000, 3000, 120, 100, 50},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
b.Run(tc.name, func(b *testing.B) {
|
||||
img := newRGBAImage(tc.w, tc.h)
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
_, _ = GenerateMiniPreviewImage(img, tc.targetW, tc.targetH, tc.quality)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkFillCenter(b *testing.B) {
|
||||
cases := []struct {
|
||||
name string
|
||||
w, h int
|
||||
targetW, targetH int
|
||||
}{
|
||||
{"2000x1500 -> 120x100", 2000, 1500, 120, 100},
|
||||
{"4000x3000 -> 120x100", 4000, 3000, 120, 100},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
b.Run(tc.name, func(b *testing.B) {
|
||||
img := newRGBAImage(tc.w, tc.h)
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
FillCenter(img, tc.targetW, tc.targetH)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkMakeImageUpright(b *testing.B) {
|
||||
orientations := []struct {
|
||||
name string
|
||||
orientation int
|
||||
}{
|
||||
{"Upright (no-op)", Upright},
|
||||
{"UpsideDown (rotate 180)", UpsideDown},
|
||||
{"RotatedCCW (rotate 270)", RotatedCCW},
|
||||
{"RotatedCW (rotate 90)", RotatedCW},
|
||||
{"UprightMirrored (flip H)", UprightMirrored},
|
||||
{"UpsideDownMirrored (flip V)", UpsideDownMirrored},
|
||||
{"RotatedCWMirrored (transpose)", RotatedCWMirrored},
|
||||
{"RotatedCCWMirrored (transverse)", RotatedCCWMirrored},
|
||||
}
|
||||
img := newRGBAImage(2000, 1500)
|
||||
for _, tc := range orientations {
|
||||
b.Run(tc.name, func(b *testing.B) {
|
||||
for b.Loop() {
|
||||
MakeImageUpright(img, tc.orientation)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
154
server/channels/app/imaging/preview_test.go
Normal file
154
server/channels/app/imaging/preview_test.go
Normal file
@@ -0,0 +1,154 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package imaging
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"flag"
|
||||
"image"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/mattermost/server/v8/channels/utils/fileutils"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var updateImagingFixtures = flag.Bool("update-fixtures", false, "overwrite imaging fixture files with actual output")
|
||||
|
||||
func TestGenerateThumbnail(t *testing.T) {
|
||||
tcs := []struct {
|
||||
name string
|
||||
inputImg image.Image
|
||||
targetWidth int
|
||||
targetHeight int
|
||||
expectedWidth int
|
||||
expectedHeight int
|
||||
}{
|
||||
{
|
||||
name: "empty image",
|
||||
inputImg: image.NewRGBA(image.Rect(0, 0, 0, 0)),
|
||||
targetWidth: 120,
|
||||
targetHeight: 100,
|
||||
},
|
||||
{
|
||||
name: "both dimensions lower than targets",
|
||||
inputImg: image.NewRGBA(image.Rect(0, 0, 100, 50)),
|
||||
targetWidth: 120,
|
||||
targetHeight: 100,
|
||||
expectedWidth: 120,
|
||||
expectedHeight: 60,
|
||||
},
|
||||
{
|
||||
name: "both dimensions equal to targets",
|
||||
inputImg: image.NewRGBA(image.Rect(0, 0, 120, 100)),
|
||||
targetWidth: 120,
|
||||
targetHeight: 100,
|
||||
expectedWidth: 120,
|
||||
expectedHeight: 100,
|
||||
},
|
||||
{
|
||||
name: "both dimensions higher than targets",
|
||||
inputImg: image.NewRGBA(image.Rect(0, 0, 1000, 500)),
|
||||
targetWidth: 120,
|
||||
targetHeight: 100,
|
||||
expectedWidth: 120,
|
||||
expectedHeight: 60,
|
||||
},
|
||||
{
|
||||
name: "width higher than target",
|
||||
inputImg: image.NewRGBA(image.Rect(0, 0, 200, 100)),
|
||||
targetWidth: 120,
|
||||
targetHeight: 100,
|
||||
expectedWidth: 120,
|
||||
expectedHeight: 60,
|
||||
},
|
||||
{
|
||||
name: "height higher than target",
|
||||
inputImg: image.NewRGBA(image.Rect(0, 0, 100, 200)),
|
||||
targetWidth: 120,
|
||||
targetHeight: 100,
|
||||
expectedWidth: 50,
|
||||
expectedHeight: 100,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tcs {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
thumb := GenerateThumbnail(tc.inputImg, tc.targetWidth, tc.targetHeight)
|
||||
require.Equal(t, tc.expectedWidth, thumb.Bounds().Dx(), "expectedWidth")
|
||||
require.Equal(t, tc.expectedHeight, thumb.Bounds().Dy(), "expectedHeight")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratePreview(t *testing.T) {
|
||||
imgDir, ok := fileutils.FindDir("tests")
|
||||
require.True(t, ok)
|
||||
|
||||
d, err := NewDecoder(DecoderOptions{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, d)
|
||||
|
||||
inputFile, err := os.Open(filepath.Join(imgDir, "qa-data-graph.png"))
|
||||
require.NoError(t, err)
|
||||
defer inputFile.Close()
|
||||
|
||||
inputImg, format, err := d.Decode(inputFile)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "png", format)
|
||||
|
||||
expectedFile, err := os.Open(filepath.Join(imgDir, "preview_test_qa_data_graph_1024.png"))
|
||||
require.NoError(t, err)
|
||||
defer expectedFile.Close()
|
||||
|
||||
expectedImg, format, err := d.Decode(expectedFile)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "png", format)
|
||||
|
||||
preview := GeneratePreview(inputImg, 1024)
|
||||
requireSameImage(t, expectedImg, preview)
|
||||
}
|
||||
|
||||
func TestGenerateMiniPreviewImage(t *testing.T) {
|
||||
imgDir, ok := fileutils.FindDir("tests")
|
||||
require.True(t, ok)
|
||||
|
||||
d, err := NewDecoder(DecoderOptions{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, d)
|
||||
|
||||
inputFile, err := os.Open(filepath.Join(imgDir, "qa-data-graph.png"))
|
||||
require.NoError(t, err)
|
||||
defer inputFile.Close()
|
||||
|
||||
inputImg, format, err := d.Decode(inputFile)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "png", format)
|
||||
|
||||
fixturePath := filepath.Join(imgDir, "mini_preview_test_qa_data_graph_16x16_q90.jpg")
|
||||
|
||||
out, err := GenerateMiniPreviewImage(inputImg, 16, 16, 90)
|
||||
require.NoError(t, err)
|
||||
|
||||
if *updateImagingFixtures {
|
||||
require.NoError(t, os.WriteFile(fixturePath, out, 0600))
|
||||
return
|
||||
}
|
||||
|
||||
expectedFile, err := os.Open(fixturePath)
|
||||
require.NoError(t, err)
|
||||
defer expectedFile.Close()
|
||||
|
||||
expectedImg, format, err := d.Decode(expectedFile)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "jpeg", format)
|
||||
|
||||
actualImg, format, err := d.Decode(bytes.NewReader(out))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "jpeg", format)
|
||||
|
||||
requireSameImage(t, expectedImg, actualImg)
|
||||
}
|
||||
86
server/channels/app/imaging/svg.go
Normal file
86
server/channels/app/imaging/svg.go
Normal file
@@ -0,0 +1,86 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package imaging
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// SVGInfo holds information for a SVG image.
|
||||
type SVGInfo struct {
|
||||
Width int
|
||||
Height int
|
||||
}
|
||||
|
||||
// ParseSVG returns information for the given SVG input data.
|
||||
func ParseSVG(svgReader io.Reader) (SVGInfo, error) {
|
||||
svgInfo := SVGInfo{
|
||||
Width: 0,
|
||||
Height: 0,
|
||||
}
|
||||
|
||||
decoder := xml.NewDecoder(svgReader)
|
||||
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
return svgInfo, err
|
||||
}
|
||||
switch t := token.(type) {
|
||||
case xml.StartElement:
|
||||
for _, attr := range t.Attr {
|
||||
if attr.Name.Local == "viewBox" {
|
||||
values := strings.Fields(attr.Value)
|
||||
if len(values) == 4 {
|
||||
width := 0
|
||||
_, widthErr := fmt.Sscan(values[2], &width)
|
||||
|
||||
height := 0
|
||||
_, heightErr := fmt.Sscan(values[3], &height)
|
||||
|
||||
if widthErr != nil || heightErr != nil {
|
||||
return svgInfo, err
|
||||
}
|
||||
|
||||
svgInfo.Width = width
|
||||
svgInfo.Height = height
|
||||
|
||||
return svgInfo, nil
|
||||
}
|
||||
}
|
||||
if attr.Name.Local == "width" {
|
||||
width := 0
|
||||
_, err := fmt.Sscan(attr.Value, &width)
|
||||
|
||||
if err != nil {
|
||||
return svgInfo, err
|
||||
}
|
||||
|
||||
svgInfo.Width = width
|
||||
}
|
||||
if attr.Name.Local == "height" {
|
||||
height := 0
|
||||
_, err := fmt.Sscan(attr.Value, &height)
|
||||
|
||||
if err != nil {
|
||||
return svgInfo, err
|
||||
}
|
||||
|
||||
svgInfo.Height = height
|
||||
}
|
||||
}
|
||||
|
||||
if svgInfo.Width == 0 || svgInfo.Height == 0 {
|
||||
return svgInfo, errors.New("unable to extract SVG dimensions")
|
||||
}
|
||||
|
||||
return svgInfo, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
86
server/channels/app/imaging/svg_test.go
Normal file
86
server/channels/app/imaging/svg_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package imaging
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
//nolint:unparam
|
||||
func generateSVGData(width int, height int, useViewBox bool, useDimensions bool, useAlternateFormat bool) io.Reader {
|
||||
var (
|
||||
viewBoxAttribute = ""
|
||||
widthAttribute = ""
|
||||
heightAttribute = ""
|
||||
)
|
||||
if useViewBox == true {
|
||||
separator := " "
|
||||
if useAlternateFormat == true {
|
||||
separator = ", "
|
||||
}
|
||||
viewBoxAttribute = fmt.Sprintf(` viewBox="0%s0%s%d%s%d"`, separator, separator, width, separator, height)
|
||||
}
|
||||
if useDimensions == true && width > 0 && height > 0 {
|
||||
units := ""
|
||||
if useAlternateFormat == true {
|
||||
units = "px"
|
||||
}
|
||||
widthAttribute = fmt.Sprintf(` width="%d%s"`, width, units)
|
||||
heightAttribute = fmt.Sprintf(` height="%d%s"`, height, units)
|
||||
}
|
||||
svgString := fmt.Sprintf(`<svg%s%s%s></svg>`, widthAttribute, heightAttribute, viewBoxAttribute)
|
||||
return strings.NewReader(svgString)
|
||||
}
|
||||
|
||||
func TestParseValidSVGData(t *testing.T) {
|
||||
var width, height int = 300, 300
|
||||
validSVGs := []io.Reader{
|
||||
generateSVGData(width, height, true, true, false), // properly formed viewBox, width & height
|
||||
generateSVGData(width, height, true, true, true), // properly formed viewBox, width & height; alternate format
|
||||
generateSVGData(width, height, false, true, false), // missing viewBox, properly formed width & height
|
||||
generateSVGData(width, height, false, true, true), // missing viewBox, properly formed width & height; alternate format
|
||||
}
|
||||
for index, svg := range validSVGs {
|
||||
svgInfo, err := ParseSVG(svg)
|
||||
if err != nil {
|
||||
t.Errorf("Should be able to parse SVG attributes at index %d, but was not able to: err = %v", index, err)
|
||||
} else {
|
||||
if svgInfo.Width != width {
|
||||
t.Errorf("Expecting a width of %d for SVG at index %d, but it was %d instead.", width, index, svgInfo.Width)
|
||||
}
|
||||
|
||||
if svgInfo.Height != height {
|
||||
t.Errorf("Expecting a height of %d for SVG at index %d, but it was %d instead.", height, index, svgInfo.Height)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInvalidSVGData(t *testing.T) {
|
||||
var width, height int = 300, 300
|
||||
invalidSVGs := []io.Reader{
|
||||
generateSVGData(width, height, false, false, false), // missing viewBox, width & height
|
||||
generateSVGData(width, 0, false, true, false), // missing viewBox, malformed width & height
|
||||
generateSVGData(width, 0, false, true, false), // missing viewBox, malformed height, properly formed width
|
||||
}
|
||||
for index, svg := range invalidSVGs {
|
||||
_, err := ParseSVG(svg)
|
||||
if err == nil {
|
||||
t.Errorf("Should not be able to parse SVG attributes at index %d, but was definitely able to!", index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseProcInstOnlySVGData(t *testing.T) {
|
||||
svg := strings.NewReader("<?xml version='1.0' encoding='utf-8'?>")
|
||||
svgInfo, err := ParseSVG(svg)
|
||||
require.Error(t, err)
|
||||
require.Equal(t, 0, svgInfo.Width)
|
||||
require.Equal(t, 0, svgInfo.Height)
|
||||
}
|
||||
153
server/channels/app/imaging/utils.go
Normal file
153
server/channels/app/imaging/utils.go
Normal file
@@ -0,0 +1,153 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package imaging
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
|
||||
"github.com/boxes-ltd/imaging"
|
||||
)
|
||||
|
||||
type rawImg interface {
|
||||
Set(x, y int, c color.Color)
|
||||
Opaque() bool
|
||||
}
|
||||
|
||||
func isFullyTransparent(c color.Color) bool {
|
||||
// TODO: This can be optimized by checking the color type and
|
||||
// only extract the needed alpha value.
|
||||
_, _, _, a := c.RGBA()
|
||||
return a == 0
|
||||
}
|
||||
|
||||
// FillImageTransparency fills in-place all the fully transparent pixels of the
|
||||
// input image with the given color.
|
||||
func FillImageTransparency(img image.Image, c color.Color) {
|
||||
var i rawImg
|
||||
|
||||
bounds := img.Bounds()
|
||||
|
||||
fillFunc := func() {
|
||||
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
|
||||
for x := bounds.Min.X; x < bounds.Max.X; x++ {
|
||||
if isFullyTransparent(img.At(x, y)) {
|
||||
i.Set(x, y, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch raw := img.(type) {
|
||||
case *image.Alpha:
|
||||
i = raw
|
||||
case *image.Alpha16:
|
||||
i = raw
|
||||
case *image.Gray:
|
||||
i = raw
|
||||
case *image.Gray16:
|
||||
i = raw
|
||||
case *image.NRGBA:
|
||||
i = raw
|
||||
col := color.NRGBAModel.Convert(c).(color.NRGBA)
|
||||
fillFunc = func() {
|
||||
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
|
||||
for x := bounds.Min.X; x < bounds.Max.X; x++ {
|
||||
i := raw.PixOffset(x, y)
|
||||
if raw.Pix[i+3] == 0x00 {
|
||||
raw.Pix[i] = col.R
|
||||
raw.Pix[i+1] = col.G
|
||||
raw.Pix[i+2] = col.B
|
||||
raw.Pix[i+3] = col.A
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
case *image.NRGBA64:
|
||||
i = raw
|
||||
col := color.NRGBA64Model.Convert(c).(color.NRGBA64)
|
||||
fillFunc = func() {
|
||||
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
|
||||
for x := bounds.Min.X; x < bounds.Max.X; x++ {
|
||||
i := raw.PixOffset(x, y)
|
||||
a := uint16(raw.Pix[i+6])<<8 | uint16(raw.Pix[i+7])
|
||||
if a == 0 {
|
||||
raw.Pix[i] = uint8(col.R >> 8)
|
||||
raw.Pix[i+1] = uint8(col.R)
|
||||
raw.Pix[i+2] = uint8(col.G >> 8)
|
||||
raw.Pix[i+3] = uint8(col.G)
|
||||
raw.Pix[i+4] = uint8(col.B >> 8)
|
||||
raw.Pix[i+5] = uint8(col.B)
|
||||
raw.Pix[i+6] = uint8(col.A >> 8)
|
||||
raw.Pix[i+7] = uint8(col.A)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
case *image.Paletted:
|
||||
i = raw
|
||||
fillFunc = func() {
|
||||
for i := range raw.Palette {
|
||||
if isFullyTransparent(raw.Palette[i]) {
|
||||
raw.Palette[i] = c
|
||||
}
|
||||
}
|
||||
}
|
||||
case *image.RGBA:
|
||||
i = raw
|
||||
col := color.RGBAModel.Convert(c).(color.RGBA)
|
||||
fillFunc = func() {
|
||||
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
|
||||
for x := bounds.Min.X; x < bounds.Max.X; x++ {
|
||||
i := raw.PixOffset(x, y)
|
||||
if raw.Pix[i+3] == 0x00 {
|
||||
raw.Pix[i] = col.R
|
||||
raw.Pix[i+1] = col.G
|
||||
raw.Pix[i+2] = col.B
|
||||
raw.Pix[i+3] = col.A
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
case *image.RGBA64:
|
||||
i = raw
|
||||
col := color.RGBA64Model.Convert(c).(color.RGBA64)
|
||||
fillFunc = func() {
|
||||
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
|
||||
for x := bounds.Min.X; x < bounds.Max.X; x++ {
|
||||
i := raw.PixOffset(x, y)
|
||||
a := uint16(raw.Pix[i+6])<<8 | uint16(raw.Pix[i+7])
|
||||
if a == 0 {
|
||||
raw.Pix[i] = uint8(col.R >> 8)
|
||||
raw.Pix[i+1] = uint8(col.R)
|
||||
raw.Pix[i+2] = uint8(col.G >> 8)
|
||||
raw.Pix[i+3] = uint8(col.G)
|
||||
raw.Pix[i+4] = uint8(col.B >> 8)
|
||||
raw.Pix[i+5] = uint8(col.B)
|
||||
raw.Pix[i+6] = uint8(col.A >> 8)
|
||||
raw.Pix[i+7] = uint8(col.A)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
if !i.Opaque() {
|
||||
fillFunc()
|
||||
}
|
||||
}
|
||||
|
||||
// FillCenter creates an image with the specified dimensions and fills it with
|
||||
// the centered and scaled source image.
|
||||
func FillCenter(img image.Image, w, h int) *image.NRGBA {
|
||||
return imaging.Fill(img, w, h, imaging.Center, imaging.Lanczos)
|
||||
}
|
||||
|
||||
// Fit scales down the image to fit within the specified maximum dimensions,
|
||||
// preserving the aspect ratio.
|
||||
func Fit(img image.Image, maxW, maxH int) *image.NRGBA {
|
||||
return imaging.Fit(img, maxW, maxH, imaging.Lanczos)
|
||||
}
|
||||
105
server/channels/app/imaging/utils_bench_test.go
Normal file
105
server/channels/app/imaging/utils_bench_test.go
Normal file
@@ -0,0 +1,105 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package imaging
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func fillImageTransparencyOld(img image.Image, c color.Color) {
|
||||
dst := image.NewRGBA(img.Bounds())
|
||||
draw.Draw(dst, dst.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src)
|
||||
draw.Draw(dst, dst.Bounds(), img, img.Bounds().Min, draw.Over)
|
||||
}
|
||||
|
||||
func fullyOpaqueGen(w, h int) func() image.Image {
|
||||
return func() image.Image {
|
||||
dst := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
draw.Draw(dst, dst.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src)
|
||||
return dst
|
||||
}
|
||||
}
|
||||
|
||||
func partiallyOpaqueGen(w, h int) func() image.Image {
|
||||
return func() image.Image {
|
||||
dst := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
draw.Draw(dst, image.Rect(0, 0, w/2, h/2), image.NewUniform(color.White), image.Point{}, draw.Src)
|
||||
return dst
|
||||
}
|
||||
}
|
||||
|
||||
func fullyTransparentGen(w, h int) func() image.Image {
|
||||
return func() image.Image {
|
||||
return image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
}
|
||||
}
|
||||
|
||||
func fullyOpaquePaletteGen(w, h int) func() image.Image {
|
||||
return func() image.Image {
|
||||
return image.NewPaletted(image.Rect(0, 0, w, h), []color.Color{image.White})
|
||||
}
|
||||
}
|
||||
|
||||
func fullyTransparentPaletteGen(w, h int) func() image.Image {
|
||||
return func() image.Image {
|
||||
return image.NewPaletted(image.Rect(0, 0, w, h), []color.Color{image.Transparent})
|
||||
}
|
||||
}
|
||||
|
||||
var tcs = []struct {
|
||||
name string
|
||||
imgGen func() image.Image
|
||||
}{
|
||||
{
|
||||
"10MPx fully transparent RGBA",
|
||||
fullyTransparentGen(1000, 1000),
|
||||
},
|
||||
{
|
||||
"10MPx partially opaque RGBA",
|
||||
partiallyOpaqueGen(1000, 1000),
|
||||
},
|
||||
{
|
||||
"10MPx fully opaque RGBA",
|
||||
fullyOpaqueGen(1000, 1000),
|
||||
},
|
||||
{
|
||||
"10MPx fully opaque palette",
|
||||
fullyOpaquePaletteGen(1000, 1000),
|
||||
},
|
||||
{
|
||||
"10MPx fully transparent palette",
|
||||
fullyTransparentPaletteGen(1000, 1000),
|
||||
},
|
||||
}
|
||||
|
||||
func BenchmarkFillImageTransparency(b *testing.B) {
|
||||
for _, tc := range tcs {
|
||||
b.Run(tc.name, func(b *testing.B) {
|
||||
for b.Loop() {
|
||||
b.StopTimer()
|
||||
img := tc.imgGen()
|
||||
b.StartTimer()
|
||||
|
||||
FillImageTransparency(img, image.White)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkFillImageTransparencyOld(b *testing.B) {
|
||||
for _, tc := range tcs {
|
||||
b.Run(tc.name, func(b *testing.B) {
|
||||
for b.Loop() {
|
||||
b.StopTimer()
|
||||
img := tc.imgGen()
|
||||
b.StartTimer()
|
||||
|
||||
fillImageTransparencyOld(img, image.White)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
255
server/channels/app/imaging/utils_test.go
Normal file
255
server/channels/app/imaging/utils_test.go
Normal file
@@ -0,0 +1,255 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package imaging
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/mattermost/server/v8/channels/utils/fileutils"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// requireSameImage asserts that want and got cover the same bounds and have
|
||||
// identical RGBA values at every pixel. We compare decoded pixels rather than
|
||||
// re-encoded byte streams because image/png is not byte-stable across Go
|
||||
// versions even for identical pixel content. On mismatch we walk every pixel
|
||||
// before failing so the report includes the total diff rate, not just the
|
||||
// first divergence.
|
||||
func requireSameImage(t *testing.T, want, got image.Image) {
|
||||
t.Helper()
|
||||
require.Equal(t, want.Bounds(), got.Bounds())
|
||||
b := got.Bounds()
|
||||
total := b.Dx() * b.Dy()
|
||||
var (
|
||||
diff int
|
||||
firstX int
|
||||
firstY int
|
||||
firstWant [4]uint32
|
||||
firstGot [4]uint32
|
||||
)
|
||||
for y := b.Min.Y; y < b.Max.Y; y++ {
|
||||
for x := b.Min.X; x < b.Max.X; x++ {
|
||||
wr, wg, wb, wa := want.At(x, y).RGBA()
|
||||
gr, gg, gb, ga := got.At(x, y).RGBA()
|
||||
if wr == gr && wg == gg && wb == gb && wa == ga {
|
||||
continue
|
||||
}
|
||||
if diff == 0 {
|
||||
firstX, firstY = x, y
|
||||
firstWant = [4]uint32{wr, wg, wb, wa}
|
||||
firstGot = [4]uint32{gr, gg, gb, ga}
|
||||
}
|
||||
diff++
|
||||
}
|
||||
}
|
||||
if diff > 0 {
|
||||
t.Fatalf("%d / %d pixels differ (%.2f%%); first at (%d, %d): want %v got %v",
|
||||
diff, total, 100*float64(diff)/float64(total),
|
||||
firstX, firstY, firstWant, firstGot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFillImageTransparency(t *testing.T) {
|
||||
tcs := []struct {
|
||||
name string
|
||||
inputName string
|
||||
outputName string
|
||||
fillColor color.Color
|
||||
}{
|
||||
{
|
||||
"8-bit Palette",
|
||||
"fill_test_8bit_palette.png",
|
||||
"fill_test_8bit_palette_out.png",
|
||||
color.RGBA{0, 255, 0, 255},
|
||||
},
|
||||
{
|
||||
"8-bit RGB",
|
||||
"fill_test_8bit_rgb.png",
|
||||
"fill_test_8bit_rgb_out.png",
|
||||
color.RGBA{0, 255, 0, 255},
|
||||
},
|
||||
{
|
||||
"8-bit RGBA",
|
||||
"fill_test_8bit_rgba.png",
|
||||
"fill_test_8bit_rgba_out.png",
|
||||
color.RGBA{0, 255, 0, 255},
|
||||
},
|
||||
{
|
||||
"16-bit RGB",
|
||||
"fill_test_16bit_rgb.png",
|
||||
"fill_test_16bit_rgb_out.png",
|
||||
color.RGBA{0, 255, 0, 255},
|
||||
},
|
||||
{
|
||||
"16-bit RGBA",
|
||||
"fill_test_16bit_rgba.png",
|
||||
"fill_test_16bit_rgba_out.png",
|
||||
color.RGBA{0, 255, 0, 255},
|
||||
},
|
||||
}
|
||||
|
||||
imgDir, ok := fileutils.FindDir("tests")
|
||||
require.True(t, ok)
|
||||
|
||||
e, err := NewEncoder(EncoderOptions{})
|
||||
require.NotNil(t, e)
|
||||
require.NoError(t, err)
|
||||
|
||||
d, err := NewDecoder(DecoderOptions{})
|
||||
require.NotNil(t, d)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, tc := range tcs {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
inputFile, err := os.Open(imgDir + "/" + tc.inputName)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, inputFile)
|
||||
defer func() {
|
||||
require.NoError(t, inputFile.Close())
|
||||
}()
|
||||
|
||||
inputImg, format, err := d.Decode(inputFile)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, inputImg)
|
||||
require.Equal(t, "png", format)
|
||||
|
||||
expectedBytes, err := os.ReadFile(imgDir + "/" + tc.outputName)
|
||||
require.NoError(t, err)
|
||||
|
||||
FillImageTransparency(inputImg, tc.fillColor)
|
||||
|
||||
var b bytes.Buffer
|
||||
err = e.EncodePNG(&b, inputImg)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expectedBytes, b.Bytes())
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("Opaque image", func(t *testing.T) {
|
||||
inputFile, err := os.Open(imgDir + "/fill_test_opaque.png")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, inputFile)
|
||||
defer func() {
|
||||
require.NoError(t, inputFile.Close())
|
||||
}()
|
||||
|
||||
inputImg, format, err := d.Decode(inputFile)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, inputImg)
|
||||
require.Equal(t, "png", format)
|
||||
|
||||
_, err = inputFile.Seek(0, 0)
|
||||
require.NoError(t, err)
|
||||
|
||||
expectedImg, format, err := d.Decode(inputFile)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, expectedImg)
|
||||
require.Equal(t, "png", format)
|
||||
|
||||
FillImageTransparency(inputImg, color.RGBA{0, 255, 0, 255})
|
||||
require.Equal(t, expectedImg, inputImg)
|
||||
})
|
||||
}
|
||||
|
||||
func TestFillCenter(t *testing.T) {
|
||||
tcs := []struct {
|
||||
name string
|
||||
outputName string
|
||||
width int
|
||||
height int
|
||||
}{
|
||||
{"100x100", "fill_test_output_100x100.png", 100, 100},
|
||||
{"45x45", "fill_test_output_45x45.png", 45, 45},
|
||||
{"100x45", "fill_test_output_100x45.png", 100, 45},
|
||||
{"45x100", "fill_test_output_45x100.png", 45, 100},
|
||||
}
|
||||
|
||||
imgDir, ok := fileutils.FindDir("tests")
|
||||
require.True(t, ok)
|
||||
|
||||
d, err := NewDecoder(DecoderOptions{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, d)
|
||||
|
||||
inputFile, err := os.Open(filepath.Join(imgDir, "fill_test_input.png"))
|
||||
require.NoError(t, err)
|
||||
defer inputFile.Close()
|
||||
|
||||
inputImg, format, err := d.Decode(inputFile)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "png", format)
|
||||
|
||||
for _, tc := range tcs {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
expectedFile, err := os.Open(filepath.Join(imgDir, tc.outputName))
|
||||
require.NoError(t, err)
|
||||
defer expectedFile.Close()
|
||||
|
||||
expectedImg, format, err := d.Decode(expectedFile)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "png", format)
|
||||
|
||||
out := FillCenter(inputImg, tc.width, tc.height)
|
||||
requireSameImage(t, expectedImg, out)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFit(t *testing.T) {
|
||||
tcs := []struct {
|
||||
name string
|
||||
inputImg image.Image
|
||||
maxW int
|
||||
maxH int
|
||||
expectedWidth int
|
||||
expectedHeight int
|
||||
}{
|
||||
{
|
||||
name: "no resize when smaller than bounds",
|
||||
inputImg: image.NewRGBA(image.Rect(0, 0, 50, 50)),
|
||||
maxW: 100,
|
||||
maxH: 100,
|
||||
expectedWidth: 50,
|
||||
expectedHeight: 50,
|
||||
},
|
||||
{
|
||||
name: "landscape clamps to width",
|
||||
inputImg: image.NewRGBA(image.Rect(0, 0, 200, 100)),
|
||||
maxW: 100,
|
||||
maxH: 100,
|
||||
expectedWidth: 100,
|
||||
expectedHeight: 50,
|
||||
},
|
||||
{
|
||||
name: "portrait clamps to height",
|
||||
inputImg: image.NewRGBA(image.Rect(0, 0, 100, 200)),
|
||||
maxW: 100,
|
||||
maxH: 100,
|
||||
expectedWidth: 50,
|
||||
expectedHeight: 100,
|
||||
},
|
||||
{
|
||||
name: "both dimensions exceed",
|
||||
inputImg: image.NewRGBA(image.Rect(0, 0, 400, 200)),
|
||||
maxW: 100,
|
||||
maxH: 100,
|
||||
expectedWidth: 100,
|
||||
expectedHeight: 50,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tcs {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
out := Fit(tc.inputImg, tc.maxW, tc.maxH)
|
||||
require.Equal(t, tc.expectedWidth, out.Bounds().Dx())
|
||||
require.Equal(t, tc.expectedHeight, out.Bounds().Dy())
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user