diff --git a/internal/imageprocessor/fit_cover_internal_test.go b/internal/imageprocessor/fit_cover_internal_test.go index 38e8db6..0b01211 100644 --- a/internal/imageprocessor/fit_cover_internal_test.go +++ b/internal/imageprocessor/fit_cover_internal_test.go @@ -5,6 +5,7 @@ import ( "image" "image/color" "image/png" + "math" "testing" ) @@ -139,3 +140,129 @@ func TestImageProcessor_FitCoverCropsTheCentre(t *testing.T) { }) } } + +// 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) + } + }) + } +}