package imageprocessor import ( "bytes" "image" "image/color" "image/png" "math" "testing" ) // TestImageProcessor_FitCoverServesThinSource verifies that FitCover serves // a thin source at the requested size. An 8192x1 source scaled to cover // 8192x8192 is 8192 times as long as the output on its long side. The centre // of an 8192x1 source with the proportions of 1x8192 is 1/8192 of a pixel // wide. Each case is also turned on its side. func TestImageProcessor_FitCoverServesThinSource(t *testing.T) { t.Parallel() tests := []struct { name string inputWidth, inputHeight int size Size }{ {"8192x1 at 8192x8192", 8192, 1, Size{Width: 8192, Height: 8192}}, {"8192x1 at 1x8192", 8192, 1, Size{Width: 1, Height: 8192}}, {"1x8192 at 8192x8192", 1, 8192, Size{Width: 8192, Height: 8192}}, {"1x8192 at 8192x1", 1, 8192, Size{Width: 8192, Height: 1}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() input := createTestJPEG(t, tt.inputWidth, tt.inputHeight) result, err := New(Params{}).Process(t.Context(), bytes.NewReader(input), &Request{Size: tt.size, Format: FormatJPEG, FitMode: FitCover}) if err != nil { t.Fatalf("Process() error = %v", err) } defer func() { _ = result.Content.Close() }() t.Logf("Process() made %dx%d", result.Width, result.Height) if result.Width != tt.size.Width || result.Height != tt.size.Height { t.Errorf("Process() made %dx%d, want %dx%d", result.Width, result.Height, tt.size.Width, tt.size.Height) } }) } } // createBandsPNG creates a PNG of three equal bands, red, green and blue, // side by side when it is wider than tall and top to bottom otherwise. func createBandsPNG(t *testing.T, width, height int) []byte { t.Helper() bands := []color.RGBA{ {R: 255, A: 255}, {G: 255, A: 255}, {B: 255, A: 255}, } img := image.NewRGBA(image.Rect(0, 0, width, height)) for y := range height { for x := range width { band := 3 * y / height if width > height { band = 3 * x / width } img.Set(x, y, bands[band]) } } return encodePNG(t, img) } // TestImageProcessor_FitCoverCropsTheCentre verifies that FitCover keeps the // centre of the source: a 300x100 source of red, green and blue bands side // by side, or a 100x300 one with them top to bottom, comes out at 50x50 as // its green band. func TestImageProcessor_FitCoverCropsTheCentre(t *testing.T) { t.Parallel() tests := []struct { name string inputWidth, inputHeight int }{ {"bands side by side", 300, 100}, {"bands top to bottom", 100, 300}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() input := createBandsPNG(t, tt.inputWidth, tt.inputHeight) result, err := New(Params{}).Process(t.Context(), bytes.NewReader(input), &Request{ Size: Size{Width: 50, Height: 50}, Format: FormatPNG, FitMode: FitCover, }) if err != nil { t.Fatalf("Process() error = %v", err) } defer func() { _ = result.Content.Close() }() output, err := png.Decode(result.Content) if err != nil { t.Fatalf("failed to decode output: %v", err) } bounds := output.Bounds() t.Logf("Process() made %dx%d", bounds.Dx(), bounds.Dy()) if bounds.Dx() != 50 || bounds.Dy() != 50 { t.Fatalf("Process() made %dx%d, want 50x50", bounds.Dx(), bounds.Dy()) } // Resampling may blend a little of the bands beside the // green one into the output's edges. for y := bounds.Min.Y; y < bounds.Max.Y; y++ { for x := bounds.Min.X; x < bounds.Max.X; x++ { r, g, b, _ := output.At(x, y).RGBA() if r>>8 > 64 || g>>8 < 192 || b>>8 > 64 { t.Fatalf("output pixel %d,%d is %d,%d,%d, want green", x, y, r>>8, g>>8, b>>8) } } } }) } } // The greys of createSquarePNG, and the grey halfway between them. const ( backgroundGrey = 192 squareGrey = 64 halfwayGrey = (backgroundGrey + squareGrey) / 2 ) // createSquarePNG creates a grey PNG side pixels square with a darker grey // square squareSide pixels wide in its centre. func createSquarePNG(t *testing.T, side, squareSide int) []byte { t.Helper() first := (side - squareSide) / 2 last := first + squareSide - 1 img := image.NewGray(image.Rect(0, 0, side, side)) for y := range side { for x := range side { grey := uint8(backgroundGrey) if x >= first && x <= last && y >= first && y <= last { grey = squareGrey } img.SetGray(x, y, color.Gray{Y: grey}) } } return encodePNG(t, img) } // squareLength returns how long the square is along greys, the greys of a // row or column of the output that crosses it: the distance between the // first and the last point where greys passes halfwayGrey, or 0 when there // is no such point. Resampling blurs the square's edges over several // pixels, and each point is placed between the two pixels on either side // of it in proportion to their greys, so the length is measured to a // fraction of a pixel. func squareLength(greys []float64) float64 { first, last := -1.0, -1.0 for i := 1; i < len(greys); i++ { before, after := greys[i-1], greys[i] if (before > halfwayGrey) == (after > halfwayGrey) { continue } point := float64(i-1) + (before-halfwayGrey)/(before-after) if first < 0 { first = point } last = point } return last - first } // TestImageProcessor_FitCoverKeepsProportions verifies that FitCover scales // both sides of the source by the same amount: a square in the centre of // the source comes out square. Its width and height are measured along the // output's middle row and column. func TestImageProcessor_FitCoverKeepsProportions(t *testing.T) { t.Parallel() tests := []struct { name string side, squareSide int size Size }{ {"16x16 at 100x60", 16, 8, Size{Width: 100, Height: 60}}, {"16x16 at 60x100", 16, 8, Size{Width: 60, Height: 100}}, {"10x10 at 100x35", 10, 2, Size{Width: 100, Height: 35}}, {"10x10 at 35x100", 10, 2, Size{Width: 35, Height: 100}}, {"64x64 at 300x100", 64, 16, Size{Width: 300, Height: 100}}, {"64x64 at 100x300", 64, 16, Size{Width: 100, Height: 300}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() input := createSquarePNG(t, tt.side, tt.squareSide) result, err := New(Params{}).Process(t.Context(), bytes.NewReader(input), &Request{Size: tt.size, Format: FormatPNG, FitMode: FitCover}) if err != nil { t.Fatalf("Process() error = %v", err) } defer func() { _ = result.Content.Close() }() output, err := png.Decode(result.Content) if err != nil { t.Fatalf("failed to decode output: %v", err) } bounds := output.Bounds() if bounds.Dx() != tt.size.Width || bounds.Dy() != tt.size.Height { t.Fatalf("Process() made %dx%d, want %dx%d", bounds.Dx(), bounds.Dy(), tt.size.Width, tt.size.Height) } row := make([]float64, bounds.Dx()) for x := range row { r, _, _, _ := output.At(x, bounds.Dy()/2).RGBA() row[x] = float64(r >> 8) } column := make([]float64, bounds.Dy()) for y := range column { r, _, _, _ := output.At(bounds.Dx()/2, y).RGBA() column[y] = float64(r >> 8) } width, height := squareLength(row), squareLength(column) t.Logf("square is %.2f wide and %.2f high", width, height) if width == 0 || math.Abs(width-height) > 0.5 { t.Errorf("square is %.2f wide and %.2f high, want it square", width, height) } }) } }