From fb33a3bb34d59402c7cdc18ac57820d07ce0a799 Mon Sep 17 00:00:00 2001 From: sneak Date: Thu, 15 Oct 2026 21:31:59 +0000 Subject: [PATCH 1/6] Test that fit=cover serves a thin source and keeps its centre (part of #282) An 8192x64 or 8192x1 source, or one turned on its side, requested at 8192x8192 with fit=cover must be served at that size, and fit=cover must keep the centre of an ordinary source. Model: opus-5-5 --- .../imageprocessor/fit_cover_internal_test.go | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 internal/imageprocessor/fit_cover_internal_test.go diff --git a/internal/imageprocessor/fit_cover_internal_test.go b/internal/imageprocessor/fit_cover_internal_test.go new file mode 100644 index 0000000..50bdc30 --- /dev/null +++ b/internal/imageprocessor/fit_cover_internal_test.go @@ -0,0 +1,148 @@ +package imageprocessor + +import ( + "bytes" + "image" + "image/color" + "image/png" + "testing" +) + +// TestImageProcessor_FitCoverServesThinSource verifies that FitCover serves +// a source of 8192x64 or 8192x1, or one of those turned on its side, at +// 8192x8192. Scaling such a source to cover that size makes it 128 or 8192 +// times as long as the output on its long side. +func TestImageProcessor_FitCoverServesThinSource(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + inputWidth, inputHeight int + }{ + {"8192x64", 8192, 64}, + {"8192x1", 8192, 1}, + {"64x8192", 64, 8192}, + {"1x8192", 1, 8192}, + } + + 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: Size{ + Width: MaxOutputDimension, + Height: MaxOutputDimension, + }, + 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 != MaxOutputDimension || + result.Height != MaxOutputDimension { + t.Errorf("Process() made %dx%d, want %dx%d", + result.Width, result.Height, + MaxOutputDimension, MaxOutputDimension) + } + }) + } +} + +// 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) + } + } + } + }) + } +} -- 2.54.0 From 96d5ccf5cc70bbaf01857b11d4ae422c6ab0c554 Mon Sep 17 00:00:00 2001 From: sneak Date: Thu, 15 Oct 2026 21:45:52 +0000 Subject: [PATCH 2/6] Crop the centre first for fit=cover (closes #282) libvips' thumbnail with a centre crop scales the whole source to cover the requested size and copies that into memory before it crops: 8192x64 at 8192x8192 made it allocate about 24 GiB, and 8192x1 failed with 500. resize now cuts the centre of the source to the requested size's proportions first and resizes that, so no image larger than the source or the output is made. The crop is the same centre as before. TODO.md says so. Model: opus-5-5 --- TODO.md | 10 +++++++ internal/imageprocessor/imageprocessor.go | 35 +++++++++++++++++++++-- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/TODO.md b/TODO.md index 4b27407..41d14c1 100644 --- a/TODO.md +++ b/TODO.md @@ -46,6 +46,16 @@ P2: security: per-origin rate limiting way, and so is an IPv6 address without brackets, which needed a signature before. Underscores are kept because `allowlist_hosts` accepts host names holding them. +- 2026-10-15 a thin source requested with `fit=cover` no longer makes libvips + build a huge image (closes #282): libvips scaled the whole source to cover the + requested size and copied that into memory before it cut out the centre, so an + 8192x64 source at 8192x8192 made it allocate about 24 GiB, and an 8192x1 + source answered 500. The image processor now cuts the centre of the source to + the requested size's proportions first and resizes that, so no image larger + than the source or the output is made; the crop is the same centre as before. + With the runtime image's libvips and JPEG output at 8192x8192, resident memory + peaked at about 44 MiB for the 8192x64 source, 40 MiB for the 8192x1 one, and + 117 MiB for a 4000x3000 one, which took 373 MiB before. - 2026-10-15 the README says how a source URL with a query is written into an image URL, and the image route accepts it (closes #255): the query follows the source path after `%3F`. pixa now reads the image URL's path decoded once, so diff --git a/internal/imageprocessor/imageprocessor.go b/internal/imageprocessor/imageprocessor.go index 2a1fbf5..bf08e98 100644 --- a/internal/imageprocessor/imageprocessor.go +++ b/internal/imageprocessor/imageprocessor.go @@ -475,8 +475,8 @@ func (p *ImageProcessor) resize( ) error { switch fit { case FitCover, "": - // Resize and crop to fill exact dimensions (default) - return img.Thumbnail(width, height, vips.InterestingCentre) + // Crop the centre and resize to fill exact dimensions (default) + return cropCentreAndResize(img, width, height) case FitContain: // Resize to fit within dimensions, maintaining aspect ratio @@ -530,6 +530,37 @@ func (p *ImageProcessor) resize( } } +// cropCentreAndResize cuts img to its centre, the largest area it holds with +// the proportions of width x height, and resizes that to width x height. +// This is the crop libvips' thumbnail makes with InterestingCentre, but +// libvips scales the whole image to cover the size and copies the result +// into memory before it crops, and for a thin image that is far larger than +// the image or the output: 8192x64 at 8192x8192 is 1048576x8192, about 24 +// GiB. Cropping first makes no image larger than img or the output. +func cropCentreAndResize(img *vips.ImageRef, width, height int) error { + imgW, imgH := img.Width(), img.Height() + + // An image wider than the requested proportions keeps its height and + // loses width, any other keeps its width and loses height; the side + // shortened is rounded to the nearest pixel and is at least 1 + cropW, cropH := imgW, imgH + if imgW*height > imgH*width { + cropW = max(1, (imgH*width+height/2)/height) + } else { + cropH = max(1, (imgW*height+width/2)/width) + } + + // Half of what is cut comes off each side + left, top := (imgW-cropW)/2, (imgH-cropH)/2 //nolint:mnd // half the cut + + err := img.ExtractArea(left, top, cropW, cropH) + if err != nil { + return err + } + + return img.ThumbnailWithSize(width, height, vips.InterestingNone, vips.SizeForce) +} + const defaultQuality = 85 // encode encodes an image to the specified format. govips gives pixa no way -- 2.54.0 From 4d59886082b5ac45ae21118f67945ae7069ebc55 Mon Sep 17 00:00:00 2001 From: sneak Date: Thu, 15 Oct 2026 22:38:03 +0000 Subject: [PATCH 3/6] Test that fit=cover serves a source whose centre is under a pixel wide (part of #282) An 8192x1 source at 1x8192, or the same turned on its side, has a centre with the requested proportions 1/8192 of a pixel wide. The crop must keep at least one pixel of it, or the request fails. The test's cases are now named by source and requested size. Model: opus-5-5 --- .../imageprocessor/fit_cover_internal_test.go | 33 ++++++++----------- 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/internal/imageprocessor/fit_cover_internal_test.go b/internal/imageprocessor/fit_cover_internal_test.go index 50bdc30..9aaa3e8 100644 --- a/internal/imageprocessor/fit_cover_internal_test.go +++ b/internal/imageprocessor/fit_cover_internal_test.go @@ -9,20 +9,24 @@ import ( ) // TestImageProcessor_FitCoverServesThinSource verifies that FitCover serves -// a source of 8192x64 or 8192x1, or one of those turned on its side, at -// 8192x8192. Scaling such a source to cover that size makes it 128 or 8192 -// times as long as the output on its long side. +// a thin source at the requested size. A source of 8192x64 or 8192x1 scaled +// to cover 8192x8192 is 128 or 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 }{ - {"8192x64", 8192, 64}, - {"8192x1", 8192, 1}, - {"64x8192", 64, 8192}, - {"1x8192", 1, 8192}, + {"8192x64 at 8192x8192", 8192, 64, Size{Width: 8192, Height: 8192}}, + {"8192x1 at 8192x8192", 8192, 1, Size{Width: 8192, Height: 8192}}, + {"8192x1 at 1x8192", 8192, 1, Size{Width: 1, Height: 8192}}, + {"64x8192 at 8192x8192", 64, 8192, Size{Width: 8192, 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 { @@ -33,14 +37,7 @@ func TestImageProcessor_FitCoverServesThinSource(t *testing.T) { result, err := New(Params{}).Process(t.Context(), bytes.NewReader(input), - &Request{ - Size: Size{ - Width: MaxOutputDimension, - Height: MaxOutputDimension, - }, - Format: FormatJPEG, - FitMode: FitCover, - }) + &Request{Size: tt.size, Format: FormatJPEG, FitMode: FitCover}) if err != nil { t.Fatalf("Process() error = %v", err) } @@ -49,11 +46,9 @@ func TestImageProcessor_FitCoverServesThinSource(t *testing.T) { t.Logf("Process() made %dx%d", result.Width, result.Height) - if result.Width != MaxOutputDimension || - result.Height != MaxOutputDimension { + if result.Width != tt.size.Width || result.Height != tt.size.Height { t.Errorf("Process() made %dx%d, want %dx%d", - result.Width, result.Height, - MaxOutputDimension, MaxOutputDimension) + result.Width, result.Height, tt.size.Width, tt.size.Height) } }) } -- 2.54.0 From 70631def348d070e851efa3e9345b45e1e4c3479 Mon Sep 17 00:00:00 2001 From: sneak Date: Thu, 15 Oct 2026 22:40:18 +0000 Subject: [PATCH 4/6] Drop the fit=cover test cases that fill memory without the fix (part of #282) Without the fix, the 8192x64 and 64x8192 cases each made libvips ask for about 24 GiB, so a return of the bug would take the build host's memory instead of failing the test. The cases left fail at once without the fix. Model: opus-5-5 --- internal/imageprocessor/fit_cover_internal_test.go | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/internal/imageprocessor/fit_cover_internal_test.go b/internal/imageprocessor/fit_cover_internal_test.go index 9aaa3e8..38e8db6 100644 --- a/internal/imageprocessor/fit_cover_internal_test.go +++ b/internal/imageprocessor/fit_cover_internal_test.go @@ -9,10 +9,10 @@ import ( ) // TestImageProcessor_FitCoverServesThinSource verifies that FitCover serves -// a thin source at the requested size. A source of 8192x64 or 8192x1 scaled -// to cover 8192x8192 is 128 or 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. +// 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() @@ -21,10 +21,8 @@ func TestImageProcessor_FitCoverServesThinSource(t *testing.T) { inputWidth, inputHeight int size Size }{ - {"8192x64 at 8192x8192", 8192, 64, Size{Width: 8192, Height: 8192}}, {"8192x1 at 8192x8192", 8192, 1, Size{Width: 8192, Height: 8192}}, {"8192x1 at 1x8192", 8192, 1, Size{Width: 1, Height: 8192}}, - {"64x8192 at 8192x8192", 64, 8192, Size{Width: 8192, Height: 8192}}, {"1x8192 at 8192x8192", 1, 8192, Size{Width: 8192, Height: 8192}}, {"1x8192 at 8192x1", 1, 8192, Size{Width: 8192, Height: 1}}, } -- 2.54.0 From eaac8fd7cf29703f1a7565bbe0b97b0eb836fad4 Mon Sep 17 00:00:00 2001 From: sneak Date: Thu, 15 Oct 2026 23:24:48 +0000 Subject: [PATCH 5/6] Test that fit=cover keeps the source's proportions (part of #282) A grey square in the centre of the source must come out square: a 16x16 source at 100x60, a 10x10 one at 100x35 and a 64x64 one at 300x100, and each turned on its side. The square's width and height are measured where its blurred edges cross the grey halfway between the square's and the background's, read to a fraction of a pixel. Model: opus-5-5 --- .../imageprocessor/fit_cover_internal_test.go | 127 ++++++++++++++++++ 1 file changed, 127 insertions(+) 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) + } + }) + } +} -- 2.54.0 From ea77bdfa6360f58593bcad678c3b9976e48720b3 Mon Sep 17 00:00:00 2001 From: sneak Date: Thu, 15 Oct 2026 23:36:42 +0000 Subject: [PATCH 6/6] Scale both sides of a fit=cover image by the same amount (part of #282) cropCentreAndResize cut the centre to whole source pixels and forced that to the requested size, so the two sides were scaled by different amounts: a 16x16 source at 100x60 came out squashed by 4%. It now widens the centre to whole source pixels and lets thumbnail with a centre crop scale that by the one amount next uses and cut the output from it. thumbnail copies the scaled image into memory, which is larger than the output by less than one scaled source pixel on each side. TODO.md says so. Model: opus-5-5 --- TODO.md | 22 +++++++------- internal/imageprocessor/imageprocessor.go | 35 ++++++++++++----------- 2 files changed, 30 insertions(+), 27 deletions(-) diff --git a/TODO.md b/TODO.md index 41d14c1..cc2a818 100644 --- a/TODO.md +++ b/TODO.md @@ -30,6 +30,18 @@ P2: security: per-origin rate limiting # Completed Steps +- 2026-10-15 a thin source requested with `fit=cover` no longer makes libvips + build a huge image (closes #282): libvips' thumbnail scaled the whole source + to cover the requested size and copied that into memory before it cut out the + centre, so an 8192x64 source at 8192x8192 made it allocate about 24 GiB, and + an 8192x1 source answered 500. The image processor now first cuts the centre + of the source, widened to whole source pixels, and thumbnail scales and cuts + only that, by the same amount on both sides as before. What it copies into + memory is then larger than the output by less than one scaled source pixel on + each side. With the runtime image's libvips and JPEG output at 8192x8192, + resident memory peaked at about 242 MiB for the 8192x64 source and 431 MiB for + the 8192x1 one. The output can sit a fraction of an output pixel from where it + was. - 2026-10-15 a `/v1/image/` URL whose source path or source query holds a `#`, written as `%23`, is refused with 400 (closes #286): the source URL built from it ended at the `#` when fetched, so each text after it fetched the same @@ -46,16 +58,6 @@ P2: security: per-origin rate limiting way, and so is an IPv6 address without brackets, which needed a signature before. Underscores are kept because `allowlist_hosts` accepts host names holding them. -- 2026-10-15 a thin source requested with `fit=cover` no longer makes libvips - build a huge image (closes #282): libvips scaled the whole source to cover the - requested size and copied that into memory before it cut out the centre, so an - 8192x64 source at 8192x8192 made it allocate about 24 GiB, and an 8192x1 - source answered 500. The image processor now cuts the centre of the source to - the requested size's proportions first and resizes that, so no image larger - than the source or the output is made; the crop is the same centre as before. - With the runtime image's libvips and JPEG output at 8192x8192, resident memory - peaked at about 44 MiB for the 8192x64 source, 40 MiB for the 8192x1 one, and - 117 MiB for a 4000x3000 one, which took 373 MiB before. - 2026-10-15 the README says how a source URL with a query is written into an image URL, and the image route accepts it (closes #255): the query follows the source path after `%3F`. pixa now reads the image URL's path decoded once, so diff --git a/internal/imageprocessor/imageprocessor.go b/internal/imageprocessor/imageprocessor.go index bf08e98..716b9ad 100644 --- a/internal/imageprocessor/imageprocessor.go +++ b/internal/imageprocessor/imageprocessor.go @@ -530,35 +530,36 @@ func (p *ImageProcessor) resize( } } -// cropCentreAndResize cuts img to its centre, the largest area it holds with -// the proportions of width x height, and resizes that to width x height. -// This is the crop libvips' thumbnail makes with InterestingCentre, but -// libvips scales the whole image to cover the size and copies the result -// into memory before it crops, and for a thin image that is far larger than -// the image or the output: 8192x64 at 8192x8192 is 1048576x8192, about 24 -// GiB. Cropping first makes no image larger than img or the output. +// cropCentreAndResize scales img by the same amount on both sides, the +// least that makes it cover width x height, and cuts the centre width x +// height out of it, as libvips' thumbnail does with InterestingCentre. +// thumbnail alone scales the whole image and copies the result into memory +// before it cuts, and for a thin image that is far larger than the image or +// the output: 8192x64 at 8192x8192 is 1048576x8192, about 24 GiB. So the +// centre is cut from img first, widened to whole pixels of img, which adds +// less than one pixel of img on each side, and thumbnail scales and cuts +// only that. func cropCentreAndResize(img *vips.ImageRef, width, height int) error { imgW, imgH := img.Width(), img.Height() // An image wider than the requested proportions keeps its height and - // loses width, any other keeps its width and loses height; the side - // shortened is rounded to the nearest pixel and is at least 1 - cropW, cropH := imgW, imgH + // loses width down to imgH*width/height, any other keeps its width and + // loses height down to imgW*height/width. Half of what is lost comes + // off each side, rounded down to whole pixels, so the part kept holds + // all of the centre. + left, top := 0, 0 if imgW*height > imgH*width { - cropW = max(1, (imgH*width+height/2)/height) + left = (imgW*height - imgH*width) / (2 * height) //nolint:mnd // each side } else { - cropH = max(1, (imgW*height+width/2)/width) + top = (imgH*width - imgW*height) / (2 * width) //nolint:mnd // each side } - // Half of what is cut comes off each side - left, top := (imgW-cropW)/2, (imgH-cropH)/2 //nolint:mnd // half the cut - - err := img.ExtractArea(left, top, cropW, cropH) + err := img.ExtractArea(left, top, imgW-2*left, imgH-2*top) if err != nil { return err } - return img.ThumbnailWithSize(width, height, vips.InterestingNone, vips.SizeForce) + return img.Thumbnail(width, height, vips.InterestingCentre) } const defaultQuality = 85 -- 2.54.0