Commit: 41d8c10b1e71ec34f4ec2c2d79e1311ad2481c78 Author: Vi Grey Date: 2023-11-11 00:20 UTC Summary: Initial release .gitignore | 1 + CHANGELOG | 9 +++++ LICENSE | 24 ++++++++++++ Makefile | 12 ++++++ go.mod | 3 ++ src/bifrost.go | 62 +++++++++++++++++++++++++++++ src/color.go | 93 ++++++++++++++++++++++++++++++++++++++++++++ src/flags.go | 167 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/wcag.go | 54 ++++++++++++++++++++++++++ 9 files changed, 425 insertions(+) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..378eac2 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +build diff --git a/CHANGELOG b/CHANGELOG new file mode 100644 index 0000000..b535cb4 --- /dev/null +++ b/CHANGELOG @@ -0,0 +1,9 @@ +# Change Log +All notable changes to this project will be documented in this file. + + +## [0.0.1] - 2023-11-11 + +### Added + +- Initial release diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..91c0826 --- /dev/null +++ b/LICENSE @@ -0,0 +1,24 @@ +Copyright (C) 2023, Vi Grey +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + 1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY AUTHOR AND CONTRIBUTORS ``AS IS'' AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL AUTHOR OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6d7ee77 --- /dev/null +++ b/Makefile @@ -0,0 +1,12 @@ +PKG_NAME := bifrost +CURRENTDIR := $(dir $(realpath $(firstword $(MAKEFILE_LIST)))) + +all: + mkdir -p $(CURRENTDIR)build/bin; \ + cd $(CURRENTDIR)src; \ + go build -ldflags="-s -w" -o $(CURRENTDIR)build/bin/$(PKG_NAME); \ + cd $(CURRENTDIR); \ + #upx --brute $(CURRENTDIR)build/bin/$(PKG_NAME); \ + +clean: + rm -rf -- $(CURRENTDIR)build; \ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4233129 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module bifrost + +go 1.21.3 diff --git a/src/bifrost.go b/src/bifrost.go new file mode 100644 index 0000000..f1445bf --- /dev/null +++ b/src/bifrost.go @@ -0,0 +1,62 @@ +package main + +import ( + "fmt" + "os" +) + +const ( + PROGRAM = "bifrost" + VERSION = "0.0.1" +) + +func init() { + if err := handleFlags(os.Args[1:]); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func main() { + var hslBG, hslFG, hslNormal, hslBig hsl + normalThreshold := 3*wcag - 1.5 + bgThreshold := 0.08 + fgThreshold := 1.0 + if !darkFlag { + bgThreshold = 1 - bgThreshold + fgThreshold = 1 - fgThreshold + } + bigThreshold := 1.5 * wcag + hslBig = hsl{hue, sat, 0.5} + hslBG = hsl{hue, bgSat, + binarySearchLuminance(hslBig, bgSat, bigThreshold, + darkFlag)} + if (hslBG.luminance >= bgThreshold && !darkFlag) || + (hslBG.luminance <= bgThreshold && darkFlag) { + hslBG.luminance = bgThreshold + } + hslFG = hsl{hue, fgSat, + binarySearchLuminance(hslBG, fgSat, normalThreshold, + !darkFlag)} + if (hslFG.luminance <= fgThreshold && !darkFlag) || + (hslFG.luminance >= fgThreshold && darkFlag) { + fmt.Println("fail") + hslFG.luminance = 1 - bgThreshold + hslBG.luminance = binarySearchLuminance(hslFG, bgSat, normalThreshold, + darkFlag) + } + hslBig.luminance = binarySearchLuminance(hslBG, sat, + bigThreshold, !darkFlag) + hslNormal = hsl{hue, sat, + binarySearchLuminance(hslBG, sat, normalThreshold, + !darkFlag)} + paletteName := "light" + if darkFlag { + paletteName = "dark" + } + fmt.Printf("%s; /* %s-bg */\n", rgbToHex(hslToRGB(hslBG)), paletteName) + fmt.Printf("%s; /* %s-fg */\n", rgbToHex(hslToRGB(hslFG)), paletteName) + fmt.Printf("%s; /* %s-normal */\n", rgbToHex(hslToRGB(hslNormal)), paletteName) + fmt.Printf("%s; /* %s-big */\n", rgbToHex(hslToRGB(hslBig)), paletteName) + +} diff --git a/src/color.go b/src/color.go new file mode 100644 index 0000000..151d67a --- /dev/null +++ b/src/color.go @@ -0,0 +1,93 @@ +package main + +import ( + "fmt" + "math" +) + +var ( + hueList = []string{"red", "orange", "yellow", "chartreuse", "green", + "spring", "cyan", "azure", "blue", "violet", "magenta", "rose"} +) + +type hsl struct { + hue float64 + saturation float64 + luminance float64 +} + +type rgb struct { + red float64 + green float64 + blue float64 +} + +func rgbToHSL(r rgb) (h hsl) { + min := math.Min(math.Min(r.red, r.blue), r.green) + max := math.Max(math.Max(r.red, r.blue), r.green) + h.luminance = (max + min) / 2 + if h.luminance > 0.5 { + h.saturation = (max - min) / (max + min) + } else { + h.saturation = (max - min) / (2 - max - min) + } + hueRad := math.Acos((r.red - r.green/2 - r.blue/2) / + math.Sqrt(math.Pow(r.red, 2)+math.Pow(r.green, 2)+math.Pow(r.blue, 2)- + r.red*r.green-r.red*r.blue-r.green*r.blue)) + if r.blue > r.green { + hueRad = 2*math.Pi - hueRad + } + if min == max { + h.hue = 0 + } else { + h.hue = math.Mod(math.Mod(hueRad/(2*math.Pi)*360, 360)+360, 360) + } + return +} + +func roundRGB(r rgb) (roundedR rgb) { + roundedR.red = math.Round(r.red*255) / 255 + roundedR.green = math.Round(r.green*255) / 255 + roundedR.blue = math.Round(r.blue*255) / 255 + return +} + +func hslToRGB(h hsl) (r rgb) { + c := (1 - math.Abs(2*h.luminance-1)) * h.saturation + m := h.luminance - c/2 + x := c * (1 - math.Abs(math.Mod(h.hue/60, 2)-1)) + switch int(h.hue / 60) { + case 0: + r.red = c + m + r.green = x + m + r.blue = m + case 1: + r.red = x + m + r.green = c + m + r.blue = m + case 2: + r.red = m + r.green = c + m + r.blue = x + m + case 3: + r.red = m + r.green = x + m + r.blue = c + m + case 4: + r.red = x + m + r.green = m + r.blue = c + m + case 5: + r.red = c + m + r.green = m + r.blue = x + m + } + return +} + +func rgbToHex(r rgb) string { + redHex := math.Round(r.red * 255) + greenHex := math.Round(r.green * 255) + blueHex := math.Round(r.blue * 255) + return fmt.Sprintf("#%02X%02X%02X", int(redHex), int(greenHex), int(blueHex)) +} diff --git a/src/flags.go b/src/flags.go new file mode 100644 index 0000000..2d7ca74 --- /dev/null +++ b/src/flags.go @@ -0,0 +1,167 @@ +package main + +/* + + */ + +import ( + "errors" + "fmt" + "math" + "os" + "strconv" + "strings" +) + +var ( + darkFlag bool + versionFlag bool + helpFlag bool + hue float64 + sat = 1.0 + fgSat = 1.0 + bgSat = 1.0 + wcag = 2.0 +) + +func getFlagStrValue(args []string, x, argsLen int) (string, error) { + if x+1 >= argsLen { + err := fmt.Errorf("%s: flag `%s` missing value\n Use `%s --help` for "+ + "details", os.Args[0], args[x], os.Args[0]) + return "", err + } + return args[x+1], nil +} + +func getFlagFloatValue(args []string, x, argsLen int) (float64, error) { + strVal, err := getFlagStrValue(args, x, argsLen) + if err != nil { + return 0, err + } + floatVal, err := strconv.ParseFloat(strVal, 64) + if err != nil { + err = fmt.Errorf("%s: flag `%s` value must be a number\nUse `%s "+ + "--help` for details", os.Args[0], args[x], os.Args[0]) + return 0, err + } + return floatVal, nil +} + +func handleFlags(args []string) (err error) { + argsLen := len(args) + for x := 0; x < argsLen; x++ { + switch strings.ToLower(args[x]) { + case "--wcag": + wcagVal, err := getFlagStrValue(args, x, argsLen) + x++ + if err != nil { + return err + } + if strings.ToLower(wcagVal) == "aa" { + wcag = 2 + } else if strings.ToLower(wcagVal) == "aaa" { + wcag = 3 + } else { + err = fmt.Errorf("%s: invalid `%s` value\n Use `%s --help` for "+ + "details", args[x], os.Args[0]) + return err + } + case "--hue-name": + hueName, err := getFlagStrValue(args, x, argsLen) + x++ + if err != nil { + return err + } + hueNameLower := strings.ToLower(hueName) + hueListLast := len(hueList) - 1 + for hueOffset := range hueList { + if hueList[hueOffset] == hueNameLower { + hue = float64(hueOffset * 30) + break + } + if hueOffset == hueListLast { + err = fmt.Errorf("%s: invalid `%s` value\nUse `%s --help` "+ + "for details", os.Args[0], args[x], os.Args[0]) + return err + } + } + case "--hue": + hueFloat, err := getFlagFloatValue(args, x, argsLen) + x++ + if err != nil { + return err + } + hue = math.Mod(math.Mod(hueFloat, 360)+360, 360) + case "--sat": + satFloat, err := getFlagFloatValue(args, x, argsLen) + x++ + if err != nil { + return err + } + sat = math.Max(math.Min(satFloat, 100), 0) / 100 + case "--bg-sat": + bgFloat, err := getFlagFloatValue(args, x, argsLen) + x++ + if err != nil { + return err + } + bgSat = math.Max(math.Min(bgFloat, 100), 0) / 100 + case "--fg-sat": + fgFloat, err := getFlagFloatValue(args, x, argsLen) + x++ + if err != nil { + return err + } + fgSat = math.Max(math.Min(fgFloat, 100), 0) / 100 + case "--dark", "-d": + darkFlag = true + break + case "--help", "-h": + helpFlag = true + break + case "--version", "-v": + versionFlag = true + default: + err = errors.New(fmt.Sprintf("%s: invalid argument `%s`\nUse `%s "+ + "--help` for details", os.Args[0], args[x], os.Args[0])) + return + } + } + if helpFlag { + displayHelp() + os.Exit(0) + } + if versionFlag { + displayVersion() + os.Exit(0) + } + fgSat *= sat + bgSat *= sat + return +} + +func displayHelp() { + fmt.Printf(`Usage %s [OPTIONS]... + +Options: + --bg-sat HSL saturation percentage of main color for + background color + -d, --dark Provide palette for dark mode + --fg-sat HSL saturation percentage of main color for + foreground color + -h, --help Print Help (this message) and exit + --hue Hue value in degrees + --hue-name Name of hue (allowed values: "red", "orange", + "yellow", "chartreuse", "green", "spring", + "cyan", "azure", "blue", "violet", "magenta", + "rose") + --sat HSL saturation percent for main color + -v, --version Print version and exit + --wcag Minimum acceptable WCAG 2.0 contrast ratio + (allowed values: "AA", "AAA" default: "AA") +`, PROGRAM) +} + +func displayVersion() { + fmt.Printf("%s %s\n", PROGRAM, VERSION) +} diff --git a/src/wcag.go b/src/wcag.go new file mode 100644 index 0000000..8a4ed1f --- /dev/null +++ b/src/wcag.go @@ -0,0 +1,54 @@ +package main + +import ( + "math" +) + +func getWCAG2RelativeLuminance(h hsl) float64 { + rgb := roundRGB(hslToRGB(h)) + var r, g, b float64 + if rgb.red <= 0.03928 { + r = rgb.red / 12.92 + } else { + r = math.Pow((rgb.red+0.055)/1.055, 2.4) + } + if rgb.green <= 0.03928 { + g = rgb.green / 12.92 + } else { + g = math.Pow((rgb.green+0.055)/1.055, 2.4) + } + if rgb.blue <= 0.03928 { + b = rgb.blue / 12.92 + } else { + b = math.Pow((rgb.blue+0.055)/1.055, 2.4) + } + return 0.2126*r + 0.7152*g + 0.0722*b +} + +func getWCAG2ContrastRatio(h1, h2 hsl) float64 { + l1 := getWCAG2RelativeLuminance(h1) + l2 := getWCAG2RelativeLuminance(h2) + if l1 < l2 { + return (l2 + 0.05) / (l1 + 0.05) + } + return (l1 + 0.05) / (l2 + 0.05) +} + +func binarySearchLuminance(h hsl, hSat, threshold float64, light bool) float64 { + start := 0.0 + end := 1.0 + for end-start > 0.0001 { + midpoint := (start + end) / 2 + hMid := hsl{hue, hSat, midpoint} + ratio := getWCAG2ContrastRatio(h, hMid) + if ratio < threshold != light { + start = midpoint + } else { + end = midpoint + } + } + if light { + return start + } + return end +}