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>
This commit is contained in:
Jesse Hallam 2026-05-11 09:56:51 -03:00 committed by GitHub
parent 068e15f31c
commit bbbfc019a9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 366 additions and 424 deletions

View file

@ -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
}

View file

@ -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))
})
}
}

View file

@ -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)
}

View 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)
}
})
}
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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())
})
}
}

View file

@ -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

View file

@ -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=

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 808 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 411 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 469 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 351 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 380 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 380 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 376 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 379 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 380 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 377 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1,021 B

After

Width:  |  Height:  |  Size: 934 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 716 B

After

Width:  |  Height:  |  Size: 623 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 702 B

After

Width:  |  Height:  |  Size: 666 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 553 B

After

Width:  |  Height:  |  Size: 518 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 649 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 300 KiB

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 302 KiB

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 301 KiB

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 302 KiB

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 301 KiB

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB