Replace bild imaging library with boxes-ltd/imaging (#36261)
* Add benchmarks for image transformation functions Covers GeneratePreview, GenerateThumbnail, GenerateMiniPreviewImage, FillCenter, and MakeImageUpright — all functions that delegate to the external imaging library. Baseline before/after the library swap. * Replace anthonynsimon/bild with boxes-ltd/imaging Reverts the imaging library swap from PR #29657 (disintegration/imaging → anthonynsimon/bild) and replaces it with github.com/boxes-ltd/imaging, the maintained fork of disintegration/imaging with the same API. - emoji.go: restore direct use of imaging.Fit with Lanczos filter - orientation.go: restore FlipH/FlipV/Rotate90/180/270/Transpose/Transverse - preview.go: restore imaging.Resize calls with Lanczos filter - utils.go: remove bild-based Resize/Fit/CropCenter helpers; restore the original FillCenter using imaging.Fill - Remove tests and test images added for the bild-specific helpers - Regenerate orientation test expected images (2,3,6,7,8) and GIF thumbnail to match boxes-ltd/imaging output https://claude.ai/code/session_012f5wLSCRQrQeRraPj282sT * gofmt imaging package files * Add tests and fixtures for the boxes-ltd/imaging-backed functions Cover MakeImageUpright across all 8 EXIF orientations, GeneratePreview, GenerateMiniPreviewImage, and FillCenter against fixtures regenerated under the new library. * Contain boxes-ltd/imaging behind the local imaging wrapper Add a Fit wrapper alongside FillCenter and route emoji.go through the local package so boxes-ltd/imaging is only imported from channels/app/imaging. * Add TestFit to cover the local Fit wrapper Dimensional checks for the Fit wrapper used by emoji.go, mirroring TestGenerateThumbnail. Pixel correctness is covered by the upstream boxes-ltd/imaging tests; this guards against wrapper-level mistakes (transposed args, wrong filter). * Address CodeRabbit nits in TestFillCenter and TestFit Decode a fresh source image per TestFillCenter subtest so cases stay isolated even if the wrapper ever mutates input. Rename the TestFit "smaller than bounds (clone)" case to "no resize when smaller than bounds" since the assertion only checks dimensions, not clone semantics. * Revert per-subtest decode in TestFillCenter imaging.Fill never mutates its input — it always returns a fresh *image.NRGBA — so re-decoding the source for every subtest was unnecessary work. Decode once, share across subtests. * Compare decoded pixels instead of raw PNG/JPEG bytes in tests image/png isn't byte-stable across Go versions, so comparing re-encoded byte streams against checked-in fixtures is brittle to toolchain bumps and re-encoding. Decode both sides and compare RGBA pixels via a shared requireSameImage helper. Fixtures stay on disk unchanged. Covers TestFillCenter, TestGeneratePreview, TestGenerateMiniPreviewImage, and TestMakeImageUpright. TestFillImageTransparency is left alone since its byte-comparison pattern predates this branch. * Report total diff rate when requireSameImage fails Walk every pixel before failing instead of stopping at the first mismatch. The failure message now includes how many pixels differ, the percentage of the image that's off, and the first divergence. --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Mattermost Build <build@mattermost.com>
|
|
@ -10,8 +10,8 @@ import (
|
|||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/anthonynsimon/bild/transform"
|
||||
"github.com/bep/imagemeta"
|
||||
"github.com/boxes-ltd/imaging"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
@ -41,19 +41,19 @@ var errStopDecoding = fmt.Errorf("stop decoding")
|
|||
func MakeImageUpright(img image.Image, orientation int) image.Image {
|
||||
switch orientation {
|
||||
case UprightMirrored:
|
||||
return transform.FlipH(img)
|
||||
return imaging.FlipH(img)
|
||||
case UpsideDown:
|
||||
return transform.Rotate(img, 180, &transform.RotationOptions{ResizeBounds: true})
|
||||
return imaging.Rotate180(img)
|
||||
case UpsideDownMirrored:
|
||||
return transform.FlipV(img)
|
||||
return imaging.FlipV(img)
|
||||
case RotatedCWMirrored:
|
||||
return transform.Rotate(transform.FlipH(img), -90, &transform.RotationOptions{ResizeBounds: true})
|
||||
return imaging.Transpose(img)
|
||||
case RotatedCCW:
|
||||
return transform.Rotate(img, 90, &transform.RotationOptions{ResizeBounds: true})
|
||||
return imaging.Rotate270(img)
|
||||
case RotatedCCWMirrored:
|
||||
return transform.Rotate(transform.FlipV(img), -90, &transform.RotationOptions{ResizeBounds: true})
|
||||
return imaging.Transverse(img)
|
||||
case RotatedCW:
|
||||
return transform.Rotate(img, 270, &transform.RotationOptions{ResizeBounds: true})
|
||||
return imaging.Rotate90(img)
|
||||
default:
|
||||
return img
|
||||
}
|
||||
|
|
|
|||
|
|
@ -197,3 +197,57 @@ func TestGetImageOrientation(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import (
|
|||
"image"
|
||||
"image/jpeg"
|
||||
|
||||
"github.com/anthonynsimon/bild/transform"
|
||||
"github.com/boxes-ltd/imaging"
|
||||
)
|
||||
|
||||
// GeneratePreview generates the preview for the given image.
|
||||
|
|
@ -18,7 +18,7 @@ func GeneratePreview(img image.Image, width int) image.Image {
|
|||
w := img.Bounds().Dx()
|
||||
|
||||
if w > width {
|
||||
preview = Resize(img, width, 0, transform.Lanczos)
|
||||
preview = imaging.Resize(img, width, 0, imaging.Lanczos)
|
||||
}
|
||||
|
||||
return preview
|
||||
|
|
@ -31,16 +31,16 @@ func GenerateThumbnail(img image.Image, targetWidth, targetHeight int) image.Ima
|
|||
|
||||
// We keep aspect ratio and ensure the output dimensions are never higher than the provided targets.
|
||||
if width > height {
|
||||
return Resize(img, targetWidth, 0, transform.Lanczos)
|
||||
return imaging.Resize(img, targetWidth, 0, imaging.Lanczos)
|
||||
}
|
||||
|
||||
return Resize(img, 0, targetHeight, transform.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 := Resize(img, w, h, transform.Lanczos)
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -4,11 +4,14 @@
|
|||
package imaging
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/anthonynsimon/bild/transform"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/utils/fileutils"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
|
|
@ -78,95 +81,64 @@ func TestGenerateThumbnail(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func createTestImage(t *testing.T, width, height int) image.Image {
|
||||
t.Helper()
|
||||
img := image.NewNRGBA(image.Rect(0, 0, width, height))
|
||||
for y := range height {
|
||||
for x := range width {
|
||||
img.Set(x, y, color.NRGBA{uint8(x % 256), uint8(y % 256), 0, 255})
|
||||
}
|
||||
}
|
||||
return img
|
||||
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 TestResize(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
img image.Image
|
||||
targetW int
|
||||
targetH int
|
||||
expectedW int
|
||||
expectedH int
|
||||
}{
|
||||
{
|
||||
name: "zero target dimensions",
|
||||
img: createTestImage(t, 100, 50),
|
||||
targetW: 0,
|
||||
targetH: 0,
|
||||
expectedW: 0,
|
||||
expectedH: 0,
|
||||
},
|
||||
{
|
||||
name: "negative target dimensions",
|
||||
img: createTestImage(t, 100, 50),
|
||||
targetW: -1,
|
||||
targetH: 25,
|
||||
expectedW: 0,
|
||||
expectedH: 0,
|
||||
},
|
||||
{
|
||||
name: "zero source dimensions",
|
||||
img: createTestImage(t, 0, 0),
|
||||
targetW: 50,
|
||||
targetH: 25,
|
||||
expectedW: 0,
|
||||
expectedH: 0,
|
||||
},
|
||||
{
|
||||
name: "preserve aspect ratio with width",
|
||||
img: createTestImage(t, 100, 50),
|
||||
targetW: 50,
|
||||
targetH: 0,
|
||||
expectedW: 50,
|
||||
expectedH: 25,
|
||||
},
|
||||
{
|
||||
name: "preserve aspect ratio with width, height > width",
|
||||
img: createTestImage(t, 50, 100),
|
||||
targetW: 50,
|
||||
targetH: 0,
|
||||
expectedW: 50,
|
||||
expectedH: 100,
|
||||
},
|
||||
{
|
||||
name: "preserve aspect ratio with height",
|
||||
img: createTestImage(t, 100, 50),
|
||||
targetW: 0,
|
||||
targetH: 25,
|
||||
expectedW: 50,
|
||||
expectedH: 25,
|
||||
},
|
||||
{
|
||||
name: "preserve aspect ratio with height, height > width",
|
||||
img: createTestImage(t, 50, 100),
|
||||
targetW: 0,
|
||||
targetH: 25,
|
||||
expectedW: 13,
|
||||
expectedH: 25,
|
||||
},
|
||||
{
|
||||
name: "valid target dimensions",
|
||||
img: createTestImage(t, 100, 50),
|
||||
targetW: 50,
|
||||
targetH: 25,
|
||||
expectedW: 50,
|
||||
expectedH: 25,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
resizedImg := Resize(tc.img, tc.targetW, tc.targetH, transform.Lanczos)
|
||||
require.Equal(t, tc.expectedW, resizedImg.Bounds().Dx())
|
||||
require.Equal(t, tc.expectedH, resizedImg.Bounds().Dy())
|
||||
})
|
||||
}
|
||||
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)
|
||||
|
||||
expectedFile, err := os.Open(filepath.Join(imgDir, "mini_preview_test_qa_data_graph_16x16_q90.jpg"))
|
||||
require.NoError(t, err)
|
||||
defer expectedFile.Close()
|
||||
|
||||
expectedImg, format, err := d.Decode(expectedFile)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "jpeg", format)
|
||||
|
||||
out, err := GenerateMiniPreviewImage(inputImg, 16, 16, 90)
|
||||
require.NoError(t, err)
|
||||
|
||||
actualImg, format, err := d.Decode(bytes.NewReader(out))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "jpeg", format)
|
||||
|
||||
requireSameImage(t, expectedImg, actualImg)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,10 +6,8 @@ package imaging
|
|||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
"math"
|
||||
|
||||
"github.com/anthonynsimon/bild/clone"
|
||||
"github.com/anthonynsimon/bild/transform"
|
||||
"github.com/boxes-ltd/imaging"
|
||||
)
|
||||
|
||||
type rawImg interface {
|
||||
|
|
@ -142,126 +140,14 @@ func FillImageTransparency(img image.Image, c color.Color) {
|
|||
}
|
||||
}
|
||||
|
||||
// CropAnchor cuts out a rectangular region with the specified size
|
||||
// from the image using the specified anchor point and returns the cropped image.
|
||||
// Adapted from github.com/disintegration/imaging
|
||||
func CropCenter(img image.Image, w, h int) image.Image {
|
||||
srcBounds := img.Bounds()
|
||||
anchorPoint := image.Pt(srcBounds.Min.X+(srcBounds.Dx()-w)/2, srcBounds.Min.Y+(srcBounds.Dy()-h)/2)
|
||||
r := image.Rect(0, 0, w, h).Add(anchorPoint)
|
||||
b := srcBounds.Intersect(r)
|
||||
return transform.Crop(img, b)
|
||||
}
|
||||
|
||||
// resizeAndCrop resizes the image to the smallest possible size that will cover the specified dimensions,
|
||||
// crops the resized image to the specified dimensions using a centered anchor point and returns
|
||||
// the transformed image.
|
||||
// Adapted from github.com/disintegration/imaging
|
||||
func resizeAndCropCenter(img image.Image, width, height int) image.Image {
|
||||
dstW, dstH := width, height
|
||||
|
||||
srcBounds := img.Bounds()
|
||||
srcW := srcBounds.Dx()
|
||||
srcH := srcBounds.Dy()
|
||||
srcAspectRatio := float64(srcW) / float64(srcH)
|
||||
dstAspectRatio := float64(dstW) / float64(dstH)
|
||||
|
||||
var tmp image.Image
|
||||
if srcAspectRatio < dstAspectRatio {
|
||||
tmp = Resize(img, dstW, 0, transform.Lanczos)
|
||||
} else {
|
||||
tmp = Resize(img, 0, dstH, transform.Lanczos)
|
||||
}
|
||||
|
||||
return CropCenter(tmp, dstW, dstH)
|
||||
}
|
||||
|
||||
// FillCenter creates an image with the specified dimensions and fills it with
|
||||
// the centered and scaled source image.
|
||||
// To achieve the correct aspect ratio without stretching, the source image will be cropped.
|
||||
// Adapted from github.com/disintegration/imaging
|
||||
func FillCenter(img image.Image, dstW, dstH int) image.Image {
|
||||
if dstW <= 0 || dstH <= 0 {
|
||||
return &image.RGBA{}
|
||||
}
|
||||
|
||||
srcBounds := img.Bounds()
|
||||
srcW := srcBounds.Dx()
|
||||
srcH := srcBounds.Dy()
|
||||
|
||||
if srcW <= 0 || srcH <= 0 {
|
||||
return &image.RGBA{}
|
||||
}
|
||||
|
||||
if srcW == dstW && srcH == dstH {
|
||||
return clone.AsShallowRGBA(img)
|
||||
}
|
||||
|
||||
return resizeAndCropCenter(img, dstW, dstH)
|
||||
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 the specified
|
||||
// maximum width and height and returns the transformed image.
|
||||
// Adapted from github.com/disintegration/imaging
|
||||
func Fit(img image.Image, maxW, maxH int) image.Image {
|
||||
if maxW <= 0 || maxH <= 0 {
|
||||
return &image.NRGBA{}
|
||||
}
|
||||
|
||||
srcBounds := img.Bounds()
|
||||
srcW := srcBounds.Dx()
|
||||
srcH := srcBounds.Dy()
|
||||
|
||||
if srcW <= 0 || srcH <= 0 {
|
||||
return &image.RGBA{}
|
||||
}
|
||||
|
||||
if srcW <= maxW && srcH <= maxH {
|
||||
return clone.AsShallowRGBA(img)
|
||||
}
|
||||
|
||||
srcAspectRatio := float64(srcW) / float64(srcH)
|
||||
maxAspectRatio := float64(maxW) / float64(maxH)
|
||||
|
||||
var newW, newH int
|
||||
if srcAspectRatio > maxAspectRatio {
|
||||
newW = maxW
|
||||
newH = int(float64(newW) / srcAspectRatio)
|
||||
} else {
|
||||
newH = maxH
|
||||
newW = int(float64(newH) * srcAspectRatio)
|
||||
}
|
||||
|
||||
return Resize(img, newW, newH, transform.Lanczos)
|
||||
}
|
||||
|
||||
// Resize resizes the image to the specified width and height using the specified resampling filter and returns the transformed image.
|
||||
// If one of width or height is 0, the image aspect ratio is preserved.
|
||||
// Adapted from github.com/disintegration/imaging
|
||||
func Resize(img image.Image, targetWidth, targetHeight int, filter transform.ResampleFilter) image.Image {
|
||||
if targetWidth < 0 || targetHeight < 0 {
|
||||
return &image.NRGBA{}
|
||||
}
|
||||
|
||||
if targetWidth == 0 && targetHeight == 0 {
|
||||
return &image.NRGBA{}
|
||||
}
|
||||
|
||||
srcW := img.Bounds().Dx()
|
||||
srcH := img.Bounds().Dy()
|
||||
if srcW <= 0 || srcH <= 0 {
|
||||
return &image.NRGBA{}
|
||||
}
|
||||
|
||||
// If new width or height is 0 then preserve aspect ratio, minimum 1px.
|
||||
if targetWidth == 0 {
|
||||
tmpW := float64(targetHeight) * float64(srcW) / float64(srcH)
|
||||
targetWidth = int(math.Max(1.0, math.Floor(tmpW+0.5)))
|
||||
}
|
||||
if targetHeight == 0 {
|
||||
tmpH := float64(targetWidth) * float64(srcH) / float64(srcW)
|
||||
targetHeight = int(math.Max(1.0, math.Floor(tmpH+0.5)))
|
||||
}
|
||||
|
||||
return transform.Resize(img, targetWidth, targetHeight, filter)
|
||||
// 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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import (
|
|||
"image"
|
||||
"image/color"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/mattermost/server/v8/channels/utils/fileutils"
|
||||
|
|
@ -15,6 +16,46 @@ import (
|
|||
"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
|
||||
|
|
@ -117,233 +158,98 @@ func TestFillImageTransparency(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestCropCenter(t *testing.T) {
|
||||
imgDir, ok := fileutils.FindDir("tests")
|
||||
require.True(t, ok)
|
||||
|
||||
d, err := NewDecoder(DecoderOptions{})
|
||||
require.NotNil(t, d)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, tc := range []struct {
|
||||
func TestFillCenter(t *testing.T) {
|
||||
tcs := []struct {
|
||||
name string
|
||||
inputName string
|
||||
outputName string
|
||||
width int
|
||||
height int
|
||||
}{
|
||||
{
|
||||
"Crop to center 100x100",
|
||||
"crop_test_input.png",
|
||||
"crop_test_output_100x100.png",
|
||||
100,
|
||||
100,
|
||||
},
|
||||
{
|
||||
"Crop to center 45x45",
|
||||
"crop_test_input.png",
|
||||
"crop_test_output_45x45.png",
|
||||
45,
|
||||
45,
|
||||
},
|
||||
{
|
||||
"Crop to center 100x45",
|
||||
"crop_test_input.png",
|
||||
"crop_test_output_100x45.png",
|
||||
100,
|
||||
45,
|
||||
},
|
||||
{
|
||||
"Crop to center 45x100",
|
||||
"crop_test_input.png",
|
||||
"crop_test_output_45x100.png",
|
||||
45,
|
||||
100,
|
||||
},
|
||||
} {
|
||||
{"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) {
|
||||
inputFile, err := os.Open(imgDir + "/" + tc.inputName)
|
||||
expectedFile, err := os.Open(filepath.Join(imgDir, tc.outputName))
|
||||
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)
|
||||
|
||||
expectedFile, err := os.Open(imgDir + "/" + tc.outputName)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, expectedFile)
|
||||
defer func() {
|
||||
require.NoError(t, expectedFile.Close())
|
||||
}()
|
||||
defer expectedFile.Close()
|
||||
|
||||
expectedImg, format, err := d.Decode(expectedFile)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, expectedImg)
|
||||
require.Equal(t, "png", format)
|
||||
|
||||
croppedImg := CropCenter(inputImg, tc.width, tc.height)
|
||||
require.Equal(t, expectedImg.Bounds().Dx(), croppedImg.Bounds().Dx())
|
||||
require.Equal(t, expectedImg.Bounds().Dy(), croppedImg.Bounds().Dy())
|
||||
require.Equal(t, expectedImg.(*image.RGBA).Pix, croppedImg.(*image.RGBA).Pix)
|
||||
out := FillCenter(inputImg, tc.width, tc.height)
|
||||
requireSameImage(t, expectedImg, out)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFit(t *testing.T) {
|
||||
imgDir, ok := fileutils.FindDir("tests")
|
||||
require.True(t, ok)
|
||||
|
||||
d, err := NewDecoder(DecoderOptions{})
|
||||
require.NotNil(t, d)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
inputName string
|
||||
outputName string
|
||||
width int
|
||||
height int
|
||||
}{
|
||||
{
|
||||
"Fit to 100x100",
|
||||
"fit_test_input.png",
|
||||
"fit_test_output_100x100.png",
|
||||
100,
|
||||
100,
|
||||
},
|
||||
{
|
||||
"Fit to 45x45",
|
||||
"fit_test_input.png",
|
||||
"fit_test_output_45x45.png",
|
||||
45,
|
||||
45,
|
||||
},
|
||||
{
|
||||
"Fit to 100x45",
|
||||
"fit_test_input.png",
|
||||
"fit_test_output_100x45.png",
|
||||
100,
|
||||
45,
|
||||
},
|
||||
{
|
||||
"Fit to 45x100",
|
||||
"fit_test_input.png",
|
||||
"fit_test_output_45x100.png",
|
||||
45,
|
||||
100,
|
||||
},
|
||||
} {
|
||||
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)
|
||||
|
||||
expectedFile, err := os.Open(imgDir + "/" + tc.outputName)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, expectedFile)
|
||||
defer func() {
|
||||
require.NoError(t, expectedFile.Close())
|
||||
}()
|
||||
|
||||
expectedImg, format, err := d.Decode(expectedFile)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, expectedImg)
|
||||
require.Equal(t, "png", format)
|
||||
|
||||
fittedImg := Fit(inputImg, tc.width, tc.height)
|
||||
require.Equal(t, expectedImg, fittedImg)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFillCenter(t *testing.T) {
|
||||
imgDir, ok := fileutils.FindDir("tests")
|
||||
require.True(t, ok)
|
||||
|
||||
d, err := NewDecoder(DecoderOptions{})
|
||||
require.NotNil(t, d)
|
||||
require.NoError(t, err)
|
||||
|
||||
tcs := []struct {
|
||||
name string
|
||||
inputName string
|
||||
outputName string
|
||||
width int
|
||||
height int
|
||||
name string
|
||||
inputImg image.Image
|
||||
maxW int
|
||||
maxH int
|
||||
expectedWidth int
|
||||
expectedHeight int
|
||||
}{
|
||||
{
|
||||
"Fill center 100x100",
|
||||
"fill_test_input.png",
|
||||
"fill_test_output_100x100.png",
|
||||
100,
|
||||
100,
|
||||
name: "no resize when smaller than bounds",
|
||||
inputImg: image.NewRGBA(image.Rect(0, 0, 50, 50)),
|
||||
maxW: 100,
|
||||
maxH: 100,
|
||||
expectedWidth: 50,
|
||||
expectedHeight: 50,
|
||||
},
|
||||
{
|
||||
"Fill center 45x45",
|
||||
"fill_test_input.png",
|
||||
"fill_test_output_45x45.png",
|
||||
45,
|
||||
45,
|
||||
name: "landscape clamps to width",
|
||||
inputImg: image.NewRGBA(image.Rect(0, 0, 200, 100)),
|
||||
maxW: 100,
|
||||
maxH: 100,
|
||||
expectedWidth: 100,
|
||||
expectedHeight: 50,
|
||||
},
|
||||
{
|
||||
"Fill center 100x45",
|
||||
"fill_test_input.png",
|
||||
"fill_test_output_100x45.png",
|
||||
100,
|
||||
45,
|
||||
name: "portrait clamps to height",
|
||||
inputImg: image.NewRGBA(image.Rect(0, 0, 100, 200)),
|
||||
maxW: 100,
|
||||
maxH: 100,
|
||||
expectedWidth: 50,
|
||||
expectedHeight: 100,
|
||||
},
|
||||
{
|
||||
"Fill center 45x100",
|
||||
"fill_test_input.png",
|
||||
"fill_test_output_45x100.png",
|
||||
45,
|
||||
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) {
|
||||
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)
|
||||
|
||||
expectedFile, err := os.Open(imgDir + "/" + tc.outputName)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, expectedFile)
|
||||
defer func() {
|
||||
require.NoError(t, expectedFile.Close())
|
||||
}()
|
||||
|
||||
expectedImg, format, err := d.Decode(expectedFile)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, expectedImg)
|
||||
require.Equal(t, "png", format)
|
||||
|
||||
filledImg := FillCenter(inputImg, tc.width, tc.height)
|
||||
require.Equal(t, expectedImg.Bounds().Dx(), filledImg.Bounds().Dx())
|
||||
require.Equal(t, expectedImg.Bounds().Dy(), filledImg.Bounds().Dy())
|
||||
require.Equal(t, expectedImg.(*image.RGBA).Pix, filledImg.(*image.RGBA).Pix)
|
||||
out := Fit(tc.inputImg, tc.maxW, tc.maxH)
|
||||
require.Equal(t, tc.expectedWidth, out.Bounds().Dx())
|
||||
require.Equal(t, tc.expectedHeight, out.Bounds().Dy())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ go 1.25.9
|
|||
require (
|
||||
code.sajari.com/docconv/v2 v2.0.0-pre.4
|
||||
github.com/Masterminds/semver/v3 v3.4.0
|
||||
github.com/anthonynsimon/bild v0.14.0
|
||||
github.com/boxes-ltd/imaging v1.7.5
|
||||
github.com/avct/uasurfer v0.0.0-20250915105040-a942f6fb6edc
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.13
|
||||
|
|
|
|||
|
|
@ -36,8 +36,6 @@ github.com/andybalholm/cascadia v1.0.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9Pq
|
|||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c=
|
||||
github.com/anthonynsimon/bild v0.14.0 h1:IFRkmKdNdqmexXHfEU7rPlAmdUZ8BDZEGtGHDnGWync=
|
||||
github.com/anthonynsimon/bild v0.14.0/go.mod h1:hcvEAyBjTW69qkKJTfpcDQ83sSZHxwOunsseDfeQhUs=
|
||||
github.com/araddon/dateparse v0.0.0-20180729174819-cfd92a431d0e/go.mod h1:SLqhdZcd+dF3TEVL2RMoob5bBP5R1P1qkox+HtCBgGI=
|
||||
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA=
|
||||
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw=
|
||||
|
|
@ -99,6 +97,8 @@ github.com/bodgit/sevenzip v1.6.1 h1:kikg2pUMYC9ljU7W9SaqHXhym5HyKm8/M/jd31fYan4
|
|||
github.com/bodgit/sevenzip v1.6.1/go.mod h1:GVoYQbEVbOGT8n2pfqCIMRUaRjQ8F9oSqoBEqZh5fQ8=
|
||||
github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4=
|
||||
github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM=
|
||||
github.com/boxes-ltd/imaging v1.7.5 h1:k4kYxJEhysoGhEEN1IEeKoSbnG8/8snjj7M48Ok0fnk=
|
||||
github.com/boxes-ltd/imaging v1.7.5/go.mod h1:+8H+oRvis3InOFtTpcoCCB1RDXqo6p9tQBtjZfWnrC8=
|
||||
github.com/bradfitz/go-smtpd v0.0.0-20170404230938-deb6d6237625/go.mod h1:HYsPBTaaSFSlLx/70C2HPIMNZpVV8+vt/A+FMnYP11g=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 808 B |
|
Before Width: | Height: | Size: 411 B |
|
Before Width: | Height: | Size: 469 B |
|
Before Width: | Height: | Size: 351 B |
BIN
server/tests/exif_samples/quadrants-orientation-2.png
Normal file
|
After Width: | Height: | Size: 380 B |
BIN
server/tests/exif_samples/quadrants-orientation-3.png
Normal file
|
After Width: | Height: | Size: 380 B |
BIN
server/tests/exif_samples/quadrants-orientation-4.png
Normal file
|
After Width: | Height: | Size: 376 B |
BIN
server/tests/exif_samples/quadrants-orientation-5.png
Normal file
|
After Width: | Height: | Size: 379 B |
BIN
server/tests/exif_samples/quadrants-orientation-6.png
Normal file
|
After Width: | Height: | Size: 380 B |
BIN
server/tests/exif_samples/quadrants-orientation-7.png
Normal file
|
After Width: | Height: | Size: 377 B |
|
Before Width: | Height: | Size: 1,021 B After Width: | Height: | Size: 934 B |
|
Before Width: | Height: | Size: 716 B After Width: | Height: | Size: 623 B |
|
Before Width: | Height: | Size: 702 B After Width: | Height: | Size: 666 B |
|
Before Width: | Height: | Size: 553 B After Width: | Height: | Size: 518 B |
|
Before Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 3 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
BIN
server/tests/mini_preview_test_qa_data_graph_16x16_q90.jpg
Normal file
|
After Width: | Height: | Size: 649 B |
|
Before Width: | Height: | Size: 300 KiB After Width: | Height: | Size: 300 KiB |
|
Before Width: | Height: | Size: 302 KiB After Width: | Height: | Size: 302 KiB |
|
Before Width: | Height: | Size: 301 KiB After Width: | Height: | Size: 301 KiB |
|
Before Width: | Height: | Size: 302 KiB After Width: | Height: | Size: 302 KiB |
|
Before Width: | Height: | Size: 301 KiB After Width: | Height: | Size: 301 KiB |
BIN
server/tests/preview_test_qa_data_graph_1024.png
Normal file
|
After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.2 KiB |