Repository: gohugoio/hugo (language: go)

Issue:
Method to sort .Colors from dark to light
Currently the `.Colors` method returns a slice sorted by dominance, which is great.

It would be convenient to get the darkest and lightest color. For example, use darkest for heading elements, lightest for background.


![image](https://user-images.githubusercontent.com/335669/202576674-0d932084-7d0d-4b17-b15b-aa1d02647cf7.png)



Discussion / hints:
I had a go with making gradients from images so this is nice.
I might be misunderstanding but I think the sort above works on the whole hex values which will have RRBBGG and with therefore bias the Red?
I think this is related:
https://discourse.gohugo.io/t/convert-hex-to-rgb/29130
And there is some discussion here of perceived luminance that I found interesting:
https://stackoverflow.com/a/596243
Ideally you'd have conversions from hex to RGB and hex to HSL
@andrewd72 I did some Google searches myself after this issue was raised, and there's lots of stuff to cover to make "everyone happy". I suspect it would be good to find one way of calculating luminance that is generally accepted that works OK for web.
This issue has been automatically marked as stale because it has not had recent activity. The resources of the Hugo team are limited, and so we are asking for your help.
If this is a **bug** and you can still reproduce this error on the <code>master</code> branch, please reply with all of the information you have about it in order to keep the issue open.
If this is a **feature request**, and you feel that it is still relevant and valuable, please tell us why.
This issue will automatically be closed in the near future if no further activity occurs. Thank you for all your contributions.
This issue has been automatically locked since there has not been any recent activity after it was closed. Please open a new issue for related bugs.
> @andrewd72 I did some Google searches myself after this issue was raised, and there's lots of stuff to cover to make "everyone happy". I suspect it would be good to find one way of calculating luminance that is generally accepted that works OK for web.

See [this](https://www.w3.org/TR/WCAG21/#dfn-relative-luminance).
This is exactly where I'm going with the work I demo here: 

https://forkful.ai/en/blog/designing-with-dynamic-image-colors-in-hugo-site-gen/

<img width="805" alt="Screenshot 2024-04-13 at 21 12 52" src="https://github.com/gohugoio/hugo/assets/150670/f43a3793-4df8-420d-881e-5dd893c24f39">

I used a proper luminance calculation to select the image border colors. For the test strips underneath, I did a very naive alpha sort.

---

I researched how to calculate luminance, read [this SO post](https://stackoverflow.com/questions/596216/formula-to-determine-perceived-brightness-of-rgb-color), which pointed to [this nice article showing the author's process](https://alienryderflex.com/hsp.html).

Here's my `Luminance` implementation from the above:

```javascript
{{/* 
	Input is expected to be a CSS-style hex color string (e.g. #RRGGBB).
	Output is the luminance from 0 to 1.

	Luminance = sqrt( 0.299*R^2 + 0.587*G^2 + 0.114*B^2 )

	See: https://alienryderflex.com/hsp.html
	     https://stackoverflow.com/questions/596216/formula-to-determine-perceived-brightness-of-rgb-color
             https://gohugo.io/methods/resource/colors/
*/}}

{{ $r := (printf "0x%s" (substr . 1 2)) | int }}
{{ $g := (printf "0x%s" (substr . 3 2)) | int }}
{{ $b := (printf "0x%s" (substr . 5 2)) | int }}

{{ $r_norm := div $r 255.0 }}
{{ $g_norm := div $g 255.0 }}
{{ $b_norm := div $b 255.0 }}

{{ return (math.Sqrt (add (pow $r_norm 2 | mul 0.299) (pow $g_norm 2 | mul 0.587) (pow $b_norm 2 | mul 0.114)))}}
```


Fix patch:
diff --git a/common/hstrings/strings.go b/common/hstrings/strings.go
index d9426ab5da0..1232eee379b 100644
--- a/common/hstrings/strings.go
+++ b/common/hstrings/strings.go
@@ -123,6 +123,20 @@ func InSlicEqualFold(arr []string, el string) bool {
 	return false
 }
 
+// ToString converts the given value to a string.
+// Note that this is a more strict version compared to cast.ToString,
+// as it will not try to convert numeric values to strings,
+// but only accept strings or fmt.Stringer.
+func ToString(v any) (string, bool) {
+	switch vv := v.(type) {
+	case string:
+		return vv, true
+	case fmt.Stringer:
+		return vv.String(), true
+	}
+	return "", false
+}
+
 type Tuple struct {
 	First  string
 	Second string
diff --git a/resources/errorResource.go b/resources/errorResource.go
index 220869fc1db..582c54f6d15 100644
--- a/resources/errorResource.go
+++ b/resources/errorResource.go
@@ -128,7 +128,7 @@ func (e *errorResource) Exif() *exif.ExifInfo {
 	panic(e.ResourceError)
 }
 
-func (e *errorResource) Colors() ([]string, error) {
+func (e *errorResource) Colors() ([]images.Color, error) {
 	panic(e.ResourceError)
 }
 
diff --git a/resources/image.go b/resources/image.go
index 78a57bb533d..8f70a665ae7 100644
--- a/resources/image.go
+++ b/resources/image.go
@@ -67,7 +67,7 @@ type imageResource struct {
 	meta        *imageMeta
 
 	dominantColorInit sync.Once
-	dominantColors    []string
+	dominantColors    []images.Color
 
 	baseResource
 }
@@ -143,7 +143,7 @@ func (i *imageResource) getExif() *exif.ExifInfo {
 
 // Colors returns a slice of the most dominant colors in an image
 // using a simple histogram method.
-func (i *imageResource) Colors() ([]string, error) {
+func (i *imageResource) Colors() ([]images.Color, error) {
 	var err error
 	i.dominantColorInit.Do(func() {
 		var img image.Image
@@ -153,7 +153,7 @@ func (i *imageResource) Colors() ([]string, error) {
 		}
 		colors := color_extractor.ExtractColors(img)
 		for _, c := range colors {
-			i.dominantColors = append(i.dominantColors, images.ColorToHexString(c))
+			i.dominantColors = append(i.dominantColors, images.ColorGoToColor(c))
 		}
 	})
 	return i.dominantColors, nil
diff --git a/resources/images/color.go b/resources/images/color.go
index 71872a30e4d..e2ff2377fdb 100644
--- a/resources/images/color.go
+++ b/resources/images/color.go
@@ -16,10 +16,76 @@ package images
 import (
 	"encoding/hex"
 	"fmt"
+	"hash/fnv"
 	"image/color"
+	"math"
 	"strings"
+
+	"github.com/gohugoio/hugo/common/hstrings"
 )
 
+type colorGoProvider interface {
+	ColorGo() color.Color
+}
+
+type Color struct {
+	// The color.
+	color color.Color
+
+	// The color prefixed with a #.
+	hex string
+
+	// The relative luminance of the color.
+	luminance float64
+}
+
+// Luminance as defined by w3.org.
+// See https://www.w3.org/TR/WCAG21/#dfn-relative-luminance
+func (c Color) Luminance() float64 {
+	return c.luminance
+}
+
+// ColorGo returns the color as a color.Color.
+// For internal use only.
+func (c Color) ColorGo() color.Color {
+	return c.color
+}
+
+// ColorHex returns the color as a hex string  prefixed with a #.
+func (c Color) ColorHex() string {
+	return c.hex
+}
+
+// String returns the color as a hex string prefixed with a #.
+func (c Color) String() string {
+	return c.hex
+}
+
+// For hashstructure. This struct is used in template func options
+// that needs to be able to hash a Color.
+// For internal use only.
+func (c Color) Hash() (uint64, error) {
+	h := fnv.New64a()
+	h.Write([]byte(c.hex))
+	return h.Sum64(), nil
+}
+
+func (c *Color) init() error {
+	c.hex = ColorGoToHexString(c.color)
+	r, g, b, _ := c.color.RGBA()
+	c.luminance = 0.2126*c.toSRGB(uint8(r)) + 0.7152*c.toSRGB(uint8(g)) + 0.0722*c.toSRGB(uint8(b))
+	return nil
+}
+
+func (c Color) toSRGB(i uint8) float64 {
+	v := float64(i) / 255
+	if v <= 0.04045 {
+		return v / 12.92
+	} else {
+		return math.Pow((v+0.055)/1.055, 2.4)
+	}
+}
+
 // AddColorToPalette adds c as the first color in p if not already there.
 // Note that it does no additional checks, so callers must make sure
 // that the palette is valid for the relevant format.
@@ -45,14 +111,60 @@ func ReplaceColorInPalette(c color.Color, p color.Palette) {
 	p[p.Index(c)] = c
 }
 
-// ColorToHexString converts a color to a hex string.
-func ColorToHexString(c color.Color) string {
+// ColorGoToHexString converts a color.Color to a hex string.
+func ColorGoToHexString(c color.Color) string {
 	r, g, b, a := c.RGBA()
 	rgba := color.RGBA{uint8(r), uint8(g), uint8(b), uint8(a)}
-	return fmt.Sprintf("#%.2x%.2x%.2x", rgba.R, rgba.G, rgba.B)
+	if rgba.A == 0xff {
+		return fmt.Sprintf("#%.2x%.2x%.2x", rgba.R, rgba.G, rgba.B)
+	}
+	return fmt.Sprintf("#%.2x%.2x%.2x%.2x", rgba.R, rgba.G, rgba.B, rgba.A)
+}
+
+// ColorGoToColor converts a color.Color to a Color.
+func ColorGoToColor(c color.Color) Color {
+	cc := Color{color: c}
+	if err := cc.init(); err != nil {
+		panic(err)
+	}
+	return cc
+}
+
+func hexStringToColor(s string) Color {
+	c, err := hexStringToColorGo(s)
+	if err != nil {
+		panic(err)
+	}
+	return ColorGoToColor(c)
+}
+
+// HexStringsToColors converts a slice of hex strings to a slice of Colors.
+func HexStringsToColors(s ...string) []Color {
+	var colors []Color
+	for _, v := range s {
+		colors = append(colors, hexStringToColor(v))
+	}
+	return colors
+}
+
+func toColorGo(v any) (color.Color, bool, error) {
+	switch vv := v.(type) {
+	case colorGoProvider:
+		return vv.ColorGo(), true, nil
+	default:
+		s, ok := hstrings.ToString(v)
+		if !ok {
+			return nil, false, nil
+		}
+		c, err := hexStringToColorGo(s)
+		if err != nil {
+			return nil, false, err
+		}
+		return c, true, nil
+	}
 }
 
-func hexStringToColor(s string) (color.Color, error) {
+func hexStringToColorGo(s string) (color.Color, error) {
 	s = strings.TrimPrefix(s, "#")
 
 	if len(s) != 3 && len(s) != 4 && len(s) != 6 && len(s) != 8 {
diff --git a/resources/images/config.go b/resources/images/config.go
index 186f8fa6bce..9655e9a51ce 100644
--- a/resources/images/config.go
+++ b/resources/images/config.go
@@ -171,7 +171,7 @@ func DecodeConfig(in map[string]any) (*config.ConfigNamespace[ImagingConfig, Ima
 			return i, nil, err
 		}
 
-		i.BgColor, err = hexStringToColor(i.Imaging.BgColor)
+		i.BgColor, err = hexStringToColorGo(i.Imaging.BgColor)
 		if err != nil {
 			return i, nil, err
 		}
@@ -230,7 +230,7 @@ func DecodeImageConfig(action string, options []string, defaults *config.ConfigN
 			c.Hint = hint
 		} else if part[0] == '#' {
 			c.BgColorStr = part[1:]
-			c.BgColor, err = hexStringToColor(c.BgColorStr)
+			c.BgColor, err = hexStringToColorGo(c.BgColorStr)
 			if err != nil {
 				return c, err
 			}
@@ -424,7 +424,7 @@ type ImagingConfigInternal struct {
 
 func (i *ImagingConfigInternal) Compile(externalCfg *ImagingConfig) error {
 	var err error
-	i.BgColor, err = hexStringToColor(externalCfg.BgColor)
+	i.BgColor, err = hexStringToColorGo(externalCfg.BgColor)
 	if err != nil {
 		return err
 	}
diff --git a/resources/images/filters.go b/resources/images/filters.go
index 53818c97d9c..0a620716d99 100644
--- a/resources/images/filters.go
+++ b/resources/images/filters.go
@@ -1,4 +1,4 @@
-// Copyright 2019 The Hugo Authors. All rights reserved.
+// Copyright 2024 The Hugo Authors. All rights reserved.
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -65,7 +65,7 @@ func (*Filters) Opacity(opacity any) gift.Filter {
 func (*Filters) Text(text string, options ...any) gift.Filter {
 	tf := textFilter{
 		text:        text,
-		color:       "#ffffff",
+		color:       color.White,
 		size:        20,
 		x:           10,
 		y:           10,
@@ -78,7 +78,9 @@ func (*Filters) Text(text string, options ...any) gift.Filter {
 		for option, v := range opt {
 			switch option {
 			case "color":
-				tf.color = cast.ToString(v)
+				if color, ok, _ := toColorGo(v); ok {
+					tf.color = color
+				}
 			case "size":
 				tf.size = cast.ToFloat64(v)
 			case "x":
@@ -128,15 +130,14 @@ func (*Filters) Padding(args ...any) gift.Filter {
 
 	var top, right, bottom, left int
 	var ccolor color.Color = color.White // canvas color
-	var err error
 
 	_args := args // preserve original args for most stable hash
 
-	if vcs, ok := (args[len(args)-1]).(string); ok {
-		ccolor, err = hexStringToColor(vcs)
+	if vcs, ok, err := toColorGo(args[len(args)-1]); ok || err != nil {
 		if err != nil {
 			panic("invalid canvas color: specify RGB or RGBA using hex notation")
 		}
+		ccolor = vcs
 		args = args[:len(args)-1]
 		if len(args) == 0 {
 			panic("not enough arguments: provide one or more padding values using the CSS shorthand property syntax")
@@ -180,12 +181,11 @@ func (*Filters) Padding(args ...any) gift.Filter {
 // Dither creates a filter that dithers an image.
 func (*Filters) Dither(options ...any) gift.Filter {
 	ditherOptions := struct {
-		Colors     []string
+		Colors     []any
 		Method     string
 		Serpentine bool
 		Strength   float32
 	}{
-		Colors:     []string{"000000ff", "ffffffff"},
 		Method:     "floydsteinberg",
 		Serpentine: true,
 		Strength:   1.0,
@@ -198,14 +198,18 @@ func (*Filters) Dither(options ...any) gift.Filter {
 		}
 	}
 
+	if len(ditherOptions.Colors) == 0 {
+		ditherOptions.Colors = []any{"000000ff", "ffffffff"}
+	}
+
 	if len(ditherOptions.Colors) < 2 {
 		panic("palette must have at least two colors")
 	}
 
 	var palette []color.Color
 	for _, c := range ditherOptions.Colors {
-		cc, err := hexStringToColor(c)
-		if err != nil {
+		cc, ok, err := toColorGo(c)
+		if !ok || err != nil {
 			panic(fmt.Sprintf("%q is an invalid color: specify RGB or RGBA using hexadecimal notation", c))
 		}
 		palette = append(palette, cc)
diff --git a/resources/images/image_resource.go b/resources/images/image_resource.go
index e6be757c26c..7cede07dd12 100644
--- a/resources/images/image_resource.go
+++ b/resources/images/image_resource.go
@@ -63,7 +63,7 @@ type ImageResourceOps interface {
 
 	// Colors returns a slice of the most dominant colors in an image
 	// using a simple histogram method.
-	Colors() ([]string, error)
+	Colors() ([]Color, error)
 
 	// For internal use.
 	DecodeImage() (image.Image, error)
diff --git a/resources/images/text.go b/resources/images/text.go
index 2d3370c6195..c1abc60bd43 100644
--- a/resources/images/text.go
+++ b/resources/images/text.go
@@ -15,6 +15,7 @@ package images
 
 import (
 	"image"
+	"image/color"
 	"image/draw"
 	"io"
 	"strings"
@@ -31,7 +32,8 @@ import (
 var _ gift.Filter = (*textFilter)(nil)
 
 type textFilter struct {
-	text, color string
+	text        string
+	color       color.Color
 	x, y        int
 	size        float64
 	linespacing int
@@ -39,11 +41,6 @@ type textFilter struct {
 }
 
 func (f textFilter) Draw(dst draw.Image, src image.Image, options *gift.Options) {
-	color, err := hexStringToColor(f.color)
-	if err != nil {
-		panic(err)
-	}
-
 	// Load and parse font
 	ttf := goregular.TTF
 	if f.fontSource != nil {
@@ -74,7 +71,7 @@ func (f textFilter) Draw(dst draw.Image, src image.Image, options *gift.Options)
 
 	d := font.Drawer{
 		Dst:  dst,
-		Src:  image.NewUniform(color),
+		Src:  image.NewUniform(f.color),
 		Face: face,
 	}
 
diff --git a/resources/transform.go b/resources/transform.go
index 39a8aaccc40..d9084b178ea 100644
--- a/resources/transform.go
+++ b/resources/transform.go
@@ -263,7 +263,7 @@ func (r *resourceAdapter) Exif() *exif.ExifInfo {
 	return r.getImageOps().Exif()
 }
 
-func (r *resourceAdapter) Colors() ([]string, error) {
+func (r *resourceAdapter) Colors() ([]images.Color, error) {
 	return r.getImageOps().Colors()
 }
 


Test patch:
diff --git a/resources/image_test.go b/resources/image_test.go
index 231a064536d..7e26c1f55e0 100644
--- a/resources/image_test.go
+++ b/resources/image_test.go
@@ -85,9 +85,16 @@ func TestImageTransformBasic(t *testing.T) {
 		assertWidthHeight(c, img, w, h)
 	}
 
-	colors, err := image.Colors()
+	gotColors, err := image.Colors()
 	c.Assert(err, qt.IsNil)
-	c.Assert(colors, qt.DeepEquals, []string{"#2d2f33", "#a49e93", "#d39e59", "#a76936", "#737a84", "#7c838b"})
+	expectedColors := images.HexStringsToColors("#2d2f33", "#a49e93", "#d39e59", "#a76936", "#737a84", "#7c838b")
+	c.Assert(len(gotColors), qt.Equals, len(expectedColors))
+	for i := range gotColors {
+		c1, c2 := gotColors[i], expectedColors[i]
+		c.Assert(c1.ColorHex(), qt.Equals, c2.ColorHex())
+		c.Assert(c1.ColorGo(), qt.DeepEquals, c2.ColorGo())
+		c.Assert(c1.Luminance(), qt.Equals, c2.Luminance())
+	}
 
 	c.Assert(image.RelPermalink(), qt.Equals, "/a/sunset.jpg")
 	c.Assert(image.ResourceType(), qt.Equals, "image")
@@ -445,6 +452,24 @@ func TestImageExif(t *testing.T) {
 	getAndCheckExif(c, image)
 }
 
+func TestImageColorsLuminance(t *testing.T) {
+	c := qt.New(t)
+
+	_, image := fetchSunset(c)
+	c.Assert(image, qt.Not(qt.IsNil))
+	colors, err := image.Colors()
+	c.Assert(err, qt.IsNil)
+	c.Assert(len(colors), qt.Equals, 6)
+	var prevLuminance float64
+	for i, color := range colors {
+		luminance := color.Luminance()
+		c.Assert(err, qt.IsNil)
+		c.Assert(luminance > 0, qt.IsTrue)
+		c.Assert(luminance, qt.Not(qt.Equals), prevLuminance, qt.Commentf("i=%d", i))
+		prevLuminance = luminance
+	}
+}
+
 func BenchmarkImageExif(b *testing.B) {
 	getImages := func(c *qt.C, b *testing.B, fs afero.Fs) []images.ImageResource {
 		spec := newTestResourceSpec(specDescriptor{fs: fs, c: c})
diff --git a/resources/images/color_test.go b/resources/images/color_test.go
index c3860a82c0c..cbbc76cf92f 100644
--- a/resources/images/color_test.go
+++ b/resources/images/color_test.go
@@ -46,7 +46,7 @@ func TestHexStringToColor(t *testing.T) {
 		c.Run(test.arg, func(c *qt.C) {
 			c.Parallel()
 
-			result, err := hexStringToColor(test.arg)
+			result, err := hexStringToColorGo(test.arg)
 
 			if b, ok := test.expect.(bool); ok && !b {
 				c.Assert(err, qt.Not(qt.IsNil))
@@ -70,13 +70,18 @@ func TestColorToHexString(t *testing.T) {
 		{color.White, "#ffffff"},
 		{color.Black, "#000000"},
 		{color.RGBA{R: 0x42, G: 0x87, B: 0xf5, A: 0xff}, "#4287f5"},
+
+		// 50% opacity.
+		// Note that the .Colors (dominant colors) received from the Image resource
+		// will always have an alpha value of 0xff.
+		{color.RGBA{R: 0x42, G: 0x87, B: 0xf5, A: 0x80}, "#4287f580"},
 	} {
 
 		test := test
 		c.Run(test.expect, func(c *qt.C) {
 			c.Parallel()
 
-			result := ColorToHexString(test.arg)
+			result := ColorGoToHexString(test.arg)
 
 			c.Assert(result, qt.Equals, test.expect)
 		})
@@ -91,9 +96,9 @@ func TestAddColorToPalette(t *testing.T) {
 
 	c.Assert(AddColorToPalette(color.White, palette), qt.HasLen, 2)
 
-	blue1, _ := hexStringToColor("34c3eb")
-	blue2, _ := hexStringToColor("34c3eb")
-	white, _ := hexStringToColor("fff")
+	blue1, _ := hexStringToColorGo("34c3eb")
+	blue2, _ := hexStringToColorGo("34c3eb")
+	white, _ := hexStringToColorGo("fff")
 
 	c.Assert(AddColorToPalette(white, palette), qt.HasLen, 2)
 	c.Assert(AddColorToPalette(blue1, palette), qt.HasLen, 3)
@@ -104,10 +109,18 @@ func TestReplaceColorInPalette(t *testing.T) {
 	c := qt.New(t)
 
 	palette := color.Palette{color.White, color.Black}
-	offWhite, _ := hexStringToColor("fcfcfc")
+	offWhite, _ := hexStringToColorGo("fcfcfc")
 
 	ReplaceColorInPalette(offWhite, palette)
 
 	c.Assert(palette, qt.HasLen, 2)
 	c.Assert(palette[0], qt.Equals, offWhite)
 }
+
+func TestColorLuminance(t *testing.T) {
+	c := qt.New(t)
+	c.Assert(hexStringToColor("#000000").Luminance(), qt.Equals, 0.0)
+	c.Assert(hexStringToColor("#768a9a").Luminance(), qt.Equals, 0.24361603589088263)
+	c.Assert(hexStringToColor("#d5bc9f").Luminance(), qt.Equals, 0.5261577672685374)
+	c.Assert(hexStringToColor("#ffffff").Luminance(), qt.Equals, 1.0)
+}
diff --git a/resources/images/config_test.go b/resources/images/config_test.go
index 86f70c1bfd7..6dd545f2cfe 100644
--- a/resources/images/config_test.go
+++ b/resources/images/config_test.go
@@ -132,7 +132,7 @@ func newImageConfig(action string, width, height, quality, rotate int, filter, a
 	c.qualitySetForImage = quality != 75
 	c.Rotate = rotate
 	c.BgColorStr = bgColor
-	c.BgColor, _ = hexStringToColor(bgColor)
+	c.BgColor, _ = hexStringToColorGo(bgColor)
 
 	if filter != "" {
 		filter = strings.ToLower(filter)