]> git.maquefel.me Git - brevno-suite/hugo/commitdiff
Add Luminance to Color
authorBjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
Mon, 15 Apr 2024 08:09:25 +0000 (10:09 +0200)
committerBjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
Tue, 16 Apr 2024 08:02:46 +0000 (10:02 +0200)
To sort an image's colors from darkest to lightest, you can then do:

```handlebars
{{ {{ $colorsByLuminance := sort $image.Colors "Luminance" }}
```

This uses the formula defined here: https://www.w3.org/TR/WCAG21/#dfn-relative-luminance

Fixes #10450

12 files changed:
common/hstrings/strings.go
resources/errorResource.go
resources/image.go
resources/image_test.go
resources/images/color.go
resources/images/color_test.go
resources/images/config.go
resources/images/config_test.go
resources/images/filters.go
resources/images/image_resource.go
resources/images/text.go
resources/transform.go

index d9426ab5da07093cf36c0fe6de74269cbe14e735..1232eee379b4ffa5c690690147578c407e2044c5 100644 (file)
@@ -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
index 220869fc1db407fd7cf50e751e033dca38eb0f74..582c54f6d15ad58cbea65ac20ae80f0a34c0cf10 100644 (file)
@@ -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)
 }
 
index 78a57bb533d1fe705119dc2f7eca35f3463ec66d..8f70a665ae7e3b10aebb9bbd9f4505774deb81cf 100644 (file)
@@ -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
index 231a064536d23e8fff05855527f47189385a1f9f..7e26c1f55e0ee5b90ab1bd4bfa4591f395891dc1 100644 (file)
@@ -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})
index 71872a30e4d77ea3367b85a1b27a68cc972c4887..e2ff2377fdb5728a1ee544bf149f3b7040b38756 100644 (file)
@@ -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 {
index c3860a82c0c543ce59d2494293dbe076e8bc1d25..cbbc76cf92fed82088aec7c17085cd957f12c1a8 100644 (file)
@@ -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)
+}
index 186f8fa6bce889a29f938ce78a4ed185a54834ce..9655e9a51cec198066522ac1ed54e52dc7238629 100644 (file)
@@ -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
        }
index 86f70c1bfd7af57ea3c451b0c006a4387964fd67..6dd545f2cfead8bc83b761b664b3396b1e2f150c 100644 (file)
@@ -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)
index 53818c97d9c48a2105e936e32459c6c9c52fe23b..0a620716d99955df47681aaa3f2198ffc0520b92 100644 (file)
@@ -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)
index e6be757c26cfc0a48f32a9c4ffd536266e8b5934..7cede07dd1234c5793ae507446f80d24a2109886 100644 (file)
@@ -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)
index 2d3370c6195ced44c675c37578a870ecbe40d500..c1abc60bd434c57233b146bc0d7eef793ea5ff47 100644 (file)
@@ -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,
        }
 
index 39a8aaccc40f9b6f82027f576f09fc592c4dde2d..d9084b178ea3675129749f1df5a62e3b3fc152cc 100644 (file)
@@ -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()
 }