diff --git a/models/issue_label.go b/models/issue_label.go index 5d50203b5a5b..936dd71e381d 100644 --- a/models/issue_label.go +++ b/models/issue_label.go @@ -8,6 +8,7 @@ package models import ( "fmt" "html/template" + "math" "regexp" "strconv" "strings" @@ -138,19 +139,44 @@ func (label *Label) BelongsToRepo() bool { return label.RepoID > 0 } +// SrgbToLinear converts a component of an sRGB color to its linear intensity +// See: https://en.wikipedia.org/wiki/SRGB#The_reverse_transformation_(sRGB_to_CIE_XYZ) +func SrgbToLinear(color uint8) float64 { + flt := float64(color) / 255 + if flt <= 0.04045 { + return flt / 12.92 + } + return math.Pow((flt+0.055)/1.055, 2.4) +} + +// Luminance returns the luminance of an sRGB color +func Luminance(color uint32) float64 { + r := SrgbToLinear(uint8(0xFF & (color >> 16))) + g := SrgbToLinear(uint8(0xFF & (color >> 8))) + b := SrgbToLinear(uint8(0xFF & color)) + + // luminance ratios for sRGB + return 0.2126*r + 0.7152*g + 0.0722*b +} + +// LuminanceThreshold is the luminance at which white and black appear to have the same contrast +// i.e. x such that 1.05 / (x + 0.05) = (x + 0.05) / 0.05 +// i.e. math.Sqrt(1.05*0.05) - 0.05 +const LuminanceThreshold float64 = 0.179 + // ForegroundColor calculates the text color for labels based // on their background color. func (label *Label) ForegroundColor() template.CSS { if strings.HasPrefix(label.Color, "#") { if color, err := strconv.ParseUint(label.Color[1:], 16, 64); err == nil { - r := float32(0xFF & (color >> 16)) - g := float32(0xFF & (color >> 8)) - b := float32(0xFF & color) - luminance := (0.2126*r + 0.7152*g + 0.0722*b) / 255 + // NOTE: see web_src/js/components/ContextPopup.vue for similar implementation + luminance := Luminance(uint32(color)) - if luminance < 0.66 { + // prefer white or black based upon contrast + if luminance < LuminanceThreshold { return template.CSS("#fff") } + return template.CSS("#000") } } diff --git a/web_src/js/components/ContextPopup.vue b/web_src/js/components/ContextPopup.vue index df04f0d8d8de..be428b477650 100644 --- a/web_src/js/components/ContextPopup.vue +++ b/web_src/js/components/ContextPopup.vue @@ -24,6 +24,22 @@ import {SvgIcon} from '../svg.js'; const {AppSubUrl} = window.config; +// NOTE: see models/issue_label.go for similar implementation +const srgbToLinear = (color) => { + color /= 255; + if (color <= 0.04045) { + return color / 12.92; + } + return ((color + 0.055) / 1.055) ** 2.4; +}; +const luminance = (colorString) => { + const r = srgbToLinear(parseInt(colorString.substring(0, 2), 16)); + const g = srgbToLinear(parseInt(colorString.substring(2, 4), 16)); + const b = srgbToLinear(parseInt(colorString.substring(4, 6), 16)); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +}; +const luminanceThreshold = 0.179; + export default { name: 'ContextPopup', @@ -74,14 +90,13 @@ export default { labels() { return this.issue.labels.map((label) => { - const red = parseInt(label.color.substring(0, 2), 16); - const green = parseInt(label.color.substring(2, 4), 16); - const blue = parseInt(label.color.substring(4, 6), 16); - let color = '#ffffff'; - if ((red * 0.299 + green * 0.587 + blue * 0.114) > 125) { - color = '#000000'; + let textColor; + if (luminance(label.color) < luminanceThreshold) { + textColor = '#ffffff'; + } else { + textColor = '#000000'; } - return {name: label.name, color: `#${label.color}`, textColor: color}; + return {name: label.name, color: `#${label.color}`, textColor}; }); } },