Repository: gohugoio/hugo (language: go)

Issue:
Add images.Mask



Discussion / hints:

Fix patch:
diff --git a/resources/images/filters.go b/resources/images/filters.go
index 99fecdf20d7..64776affee5 100644
--- a/resources/images/filters.go
+++ b/resources/images/filters.go
@@ -52,6 +52,14 @@ func (*Filters) Overlay(src ImageSource, x, y any) gift.Filter {
 	}
 }
 
+// Mask creates a filter that applies a mask image to the source image.
+func (*Filters) Mask(mask ImageSource) gift.Filter {
+	return filter{
+		Options: newFilterOpts(mask.Key()),
+		Filter:  maskFilter{mask: mask},
+	}
+}
+
 // Opacity creates a filter that changes the opacity of an image.
 // The opacity parameter must be in range (0, 1).
 func (*Filters) Opacity(opacity any) gift.Filter {
diff --git a/resources/images/mask.go b/resources/images/mask.go
new file mode 100644
index 00000000000..5ce7c5d43e3
--- /dev/null
+++ b/resources/images/mask.go
@@ -0,0 +1,63 @@
+package images
+
+import (
+	"fmt"
+	"image"
+	"image/color"
+	"image/draw"
+
+	"github.com/disintegration/gift"
+)
+
+// maskFilter applies a mask image to a base image.
+type maskFilter struct {
+	mask ImageSource
+}
+
+// Draw applies the mask to the base image.
+func (f maskFilter) Draw(dst draw.Image, baseImage image.Image, options *gift.Options) {
+	maskImage, err := f.mask.DecodeImage()
+	if err != nil {
+		panic(fmt.Sprintf("failed to decode image: %s", err))
+	}
+
+	// Ensure the mask is the same size as the base image
+	baseBounds := baseImage.Bounds()
+	maskBounds := maskImage.Bounds()
+
+	// Resize mask to match base image size if necessary
+	if maskBounds.Dx() != baseBounds.Dx() || maskBounds.Dy() != baseBounds.Dy() {
+		g := gift.New(gift.Resize(baseBounds.Dx(), baseBounds.Dy(), gift.LanczosResampling))
+		resizedMask := image.NewRGBA(g.Bounds(maskImage.Bounds()))
+		g.Draw(resizedMask, maskImage)
+		maskImage = resizedMask
+	}
+
+	// Use gift to convert the resized mask to grayscale
+	g := gift.New(gift.Grayscale())
+	grayscaleMask := image.NewGray(g.Bounds(maskImage.Bounds()))
+	g.Draw(grayscaleMask, maskImage)
+
+	// Convert grayscale mask to alpha mask
+	alphaMask := image.NewAlpha(baseBounds)
+	for y := baseBounds.Min.Y; y < baseBounds.Max.Y; y++ {
+		for x := baseBounds.Min.X; x < baseBounds.Max.X; x++ {
+			grayValue := grayscaleMask.GrayAt(x, y).Y
+			alphaMask.SetAlpha(x, y, color.Alpha{A: grayValue})
+		}
+	}
+
+	// Create an RGBA output image
+	outputImage := image.NewRGBA(baseBounds)
+
+	// Apply the mask using draw.DrawMask
+	draw.DrawMask(outputImage, baseBounds, baseImage, image.Point{}, alphaMask, image.Point{}, draw.Over)
+
+	// Copy the result to the destination
+	gift.New().Draw(dst, outputImage)
+}
+
+// Bounds returns the bounds of the resulting image.
+func (f maskFilter) Bounds(imgBounds image.Rectangle) image.Rectangle {
+	return image.Rect(0, 0, imgBounds.Dx(), imgBounds.Dy())
+}


Test patch:
diff --git a/resources/images/images_golden_integration_test.go b/resources/images/images_golden_integration_test.go
index 20f3d23f99e..49d90a79394 100644
--- a/resources/images/images_golden_integration_test.go
+++ b/resources/images/images_golden_integration_test.go
@@ -133,6 +133,57 @@ Home.
 	runGolden(t, name, files)
 }
 
+func TestGoldenFiltersMask(t *testing.T) {
+	t.Parallel()
+
+	if skipGolden {
+		t.Skip("Skip golden test on this architecture")
+	}
+
+	// Will be used to generate golden files.
+	name := "filters_mask"
+
+	files := `
+-- hugo.toml --
+[imaging]
+  bgColor = '#ebcc34'
+  hint = 'photo'
+  quality = 75
+  resampleFilter = 'Lanczos'
+-- assets/sunset.jpg --
+sourcefilename: ../testdata/sunset.jpg
+-- assets/mask.png --
+sourcefilename: ../testdata/mask.png
+
+-- layouts/index.html --
+Home.
+{{ $sunset := resources.Get "sunset.jpg" }}
+{{ $mask := resources.Get "mask.png" }}
+
+{{ template "mask" (dict "name" "transparant.png" "base" $sunset  "mask" $mask) }}
+{{ template "mask" (dict "name" "yellow.jpg" "base" $sunset  "mask" $mask) }}
+{{ template "mask" (dict "name" "wide.jpg" "base" $sunset "mask" $mask "spec" "resize 600x200") }}
+
+
+{{ define "mask"}}
+{{ $ext := path.Ext .name }}
+{{ if lt (len (path.Ext .name)) 4 }}
+	{{ errorf "No extension in %q" .name }}
+{{ end }}
+{{ $format := strings.TrimPrefix "." $ext }}
+{{ $spec := .spec | default (printf "resize 300x300 %s" $format) }}
+{{ $filters := slice (images.Process $spec) (images.Mask .mask) }}
+{{ $name := printf "images/%s" .name  }}
+{{ $img := .base.Filter $filters }}
+{{ with $img | resources.Copy $name }}
+{{ .Publish }}
+{{ end }}
+{{ end }}
+`
+
+	runGolden(t, name, files)
+}
+
 func TestGoldenFiltersText(t *testing.T) {
 	t.Parallel()
 
diff --git a/resources/images/testdata/images_golden/filters_mask/transparant.png b/resources/images/testdata/images_golden/filters_mask/transparant.png
new file mode 100644
index 00000000000..ffbd84c9636
Binary files /dev/null and b/resources/images/testdata/images_golden/filters_mask/transparant.png differ
diff --git a/resources/images/testdata/images_golden/filters_mask/wide.jpg b/resources/images/testdata/images_golden/filters_mask/wide.jpg
new file mode 100644
index 00000000000..38ef715ba8c
Binary files /dev/null and b/resources/images/testdata/images_golden/filters_mask/wide.jpg differ
diff --git a/resources/images/testdata/images_golden/filters_mask/yellow.jpg b/resources/images/testdata/images_golden/filters_mask/yellow.jpg
new file mode 100644
index 00000000000..a87123536ea
Binary files /dev/null and b/resources/images/testdata/images_golden/filters_mask/yellow.jpg differ
diff --git a/resources/testdata/mask.png b/resources/testdata/mask.png
new file mode 100644
index 00000000000..26ac8579194
Binary files /dev/null and b/resources/testdata/mask.png differ