From e197c7b29d8814d098bd53e9e7efd97c70f8de5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Erik=20Pedersen?= Date: Mon, 15 Apr 2024 10:09:25 +0200 Subject: [PATCH] Add Luminance to Color 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 --- common/hstrings/strings.go | 14 ++++ resources/errorResource.go | 2 +- resources/image.go | 6 +- resources/image_test.go | 29 ++++++- resources/images/color.go | 120 ++++++++++++++++++++++++++++- resources/images/color_test.go | 25 ++++-- resources/images/config.go | 6 +- resources/images/config_test.go | 2 +- resources/images/filters.go | 24 +++--- resources/images/image_resource.go | 2 +- resources/images/text.go | 11 +-- resources/transform.go | 2 +- 12 files changed, 204 insertions(+), 39 deletions(-) diff --git a/common/hstrings/strings.go b/common/hstrings/strings.go index d9426ab5d..1232eee37 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 220869fc1..582c54f6d 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 78a57bb53..8f70a665a 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/image_test.go b/resources/image_test.go index 231a06453..7e26c1f55 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.go b/resources/images/color.go index 71872a30e..e2ff2377f 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) } -func hexStringToColor(s string) (color.Color, error) { +// 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 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/color_test.go b/resources/images/color_test.go index c3860a82c..cbbc76cf9 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.go b/resources/images/config.go index 186f8fa6b..9655e9a51 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/config_test.go b/resources/images/config_test.go index 86f70c1bf..6dd545f2c 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) diff --git a/resources/images/filters.go b/resources/images/filters.go index 53818c97d..0a620716d 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 e6be757c2..7cede07dd 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 2d3370c61..c1abc60bd 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 39a8aaccc..d9084b178 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() }