From 2628b2efa16e8fdc60d2abf53c0188d27ee7b857 Mon Sep 17 00:00:00 2001 From: Lixen Wraith Date: Tue, 14 Jul 2026 12:56:03 -0400 Subject: [PATCH] v0.1.0 initial commit --- .gitignore | 8 +++ LICENSE | 28 ++++++++ README.md | 96 +++++++++++++++++++++++++++ blend.go | 187 +++++++++++++++++++++++++++++++++++++++++++++++++++++ doc.go | 28 ++++++++ go.mod | 3 + palette.go | 163 ++++++++++++++++++++++++++++++++++++++++++++++ rgb.go | 136 ++++++++++++++++++++++++++++++++++++++ 8 files changed, 649 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 blend.go create mode 100644 doc.go create mode 100644 go.mod create mode 100644 palette.go create mode 100644 rgb.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9f0d689 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.idea +bin/ +dev/ +logs/ +log/ +examples/ +catalog.txt +combined.txt diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1a36bbd --- /dev/null +++ b/LICENSE @@ -0,0 +1,28 @@ +BSD 3-Clause License + +Copyright (c) 2026, Lixen Wraith + +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. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS 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 THE COPYRIGHT HOLDER 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/README.md b/README.md new file mode 100644 index 0000000..1fe8303 --- /dev/null +++ b/README.md @@ -0,0 +1,96 @@ +# color + +24-bit RGB values, perceptual metrics, and blend operations. No output device, no dependencies. + +Extracted from [lixenwraith/terminal](https://github.com/lixenwraith/terminal) so renderers — terminal, GUI, image, framebuffer — share one color type and one set of operations without linking terminal I/O, `x/sys`, or termios. + +## Install + +``` +go get github.com/lixenwraith/color +``` + +Go 1.26+. Standard library only. + +## Type + +```go +type RGB struct { + R uint8 `toml:"r"` + G uint8 `toml:"g"` + B uint8 `toml:"b"` +} +``` + +Three bytes, comparable, pointer-free: safe to embed in dense cell or pixel buffers and to bind directly from config. + +`RGB` implements `image/color.Color`, so values pass into `image`, `draw`, and GUI toolkit pipelines unchanged. `From` converts back, un-premultiplying alpha and discarding it. + +```go +img.Set(x, y, color.Amber) // RGB satisfies image/color.Color +c := color.From(img.At(x, y)) // back to RGB +``` + +Where both packages are needed at one site, alias the standard library: `import stdcolor "image/color"`. + +## Operations + +| Call | Behavior | +| --- | --- | +| `Blend(dst, src, alpha)` | Linear alpha compositing | +| `SoftLight(dst, src, intensity)` | Perez soft light; gentler than linear alpha | +| `Overlay(dst, src, alpha)` | Multiply on darks, screen on lights | +| `Screen(dst, src, alpha)` | Always lightens; glow accumulation without `Add` clipping | +| `Add(dst, src, alpha)` | Saturating additive | +| `Max(dst, src, alpha)` | Per-channel maximum | +| `Scale(c, factor)` | Channel multiply, saturating | +| `Grayscale(c)`, `Luma(c)` | Rec. 601 luma | +| `c.Lerp(other, t)` | Linear interpolation | +| `RedmeanDistance(a, b)` | Squared perceptual distance, for nearest-color search | + +All operations are pure. `alpha` and `t` clamp to `[0,1]`; channels saturate. Integer paths avoid division; soft light is table-driven, no `sqrt` per channel. + +```go +bg := color.Obsidian +glow := color.Screen(bg, color.Amber, 0.4) +edge := bg.Lerp(color.Amber, 0.75) +warm := color.SoftLight(edge, color.Terracotta, 0.3) +``` + +## Palette + +~120 named colors, grouped by hue and ordered dark-to-light: `Obsidian`, `Amber`, `EmeraldGreen`, `LightSkyBlue`, `Vermilion`, … Standard names (CSS, X11) where the RGB matches; descriptive compounds otherwise. + +Package-level `var`s. **Read-only by contract** — the language permits assignment, the package does not. Alias them into domain parameter files rather than mutating them. + +## Hex + +```go +c, err := color.ParseHex("#4a90d9") // also "4a90d9", "#abc", "abc" +s := c.Hex() // "#4a90d9" +var Accent = color.MustParseHex("#ff8800") +``` + +`RGB` deliberately does **not** implement `encoding.TextUnmarshaler`. TOML and JSON decoders prefer it over struct-field unification, which would silently break table-form config: + +```toml +accent = { r = 255, g = 136, b = 0 } +``` + +## With terminal + +`terminal.Cell` carries `color.RGB` directly. Quantization stays device-side: + +```go +idx := terminal.RGBTo256(color.EmeraldGreen) // xterm-256 index +``` + +Note: when `Cell.Attrs` sets `AttrFg256` / `AttrBg256`, `Cell.Fg.R` / `Cell.Bg.R` hold a palette index, not a channel. Such values are not colors and must not be passed to this package. + +## Concurrency + +Values are immutable, operations are pure, lookup tables are built at package init. Safe for concurrent use. + +## License + +See `LICENSE`. diff --git a/blend.go b/blend.go new file mode 100644 index 0000000..4cc3420 --- /dev/null +++ b/blend.go @@ -0,0 +1,187 @@ +package color + +import "math" + +const softLightLUTSize = 256 + +// Perez SoftLight lookup tables (array access, no pointers) +// Pre-computed at init to avoid sqrt/division in per-cell loops +var ( + softLightG [softLightLUTSize]float64 + softLightDF [softLightLUTSize]float64 +) + +func init() { + for i := range softLightLUTSize { + df := float64(i) / 255.0 + softLightDF[i] = df + if df <= 0.25 { + softLightG[i] = ((16.0*df-12.0)*df + 4.0) * df + } else { + softLightG[i] = math.Sqrt(df) + } + } +} + +// clampU8 converts float to uint8 with saturation +func clampU8(v float64) uint8 { + if v >= 255.0 { + return 255 + } + if v <= 0.0 { + return 0 + } + // Round-half-up; unifies rounding across Blend/Scale/Lerp/SoftLight + return uint8(v + 0.5) +} + +// addU8 is saturating uint8 addition +func addU8(a, b uint8) uint8 { + sum := int(a) + int(b) + if sum > 255 { + return 255 + } + return uint8(sum) +} + +// fastDiv255 approximates x / 255 using integer math: (x + (x >> 8) + 1) >> 8 +// Faster than DIV instruction, exact for x in [0, 255*255] +func fastDiv255(x int) int { + return (x + (x >> 8) + 1) >> 8 +} + +// softLightChannel applies Perez soft light to one channel via LUTs +func softLightChannel(d, s uint8, intensity float64) uint8 { + df := softLightDF[d] + sf := softLightDF[s] + + var result float64 + if sf < 0.5 { + result = df - (1.0-2.0*sf)*df*(1.0-df) + } else { + // LUT replaces math.Sqrt + result = df + (2.0*sf-1.0)*(softLightG[d]-df) + } + + // Lerp toward result by intensity, single dependency chain + result = df + (result-df)*intensity + + return clampU8(result * 255.0) +} + +// overlayChannel combines multiply (d < 128) and screen (d >= 128), +// preserving destination highlights and shadows +func overlayChannel(d, s uint8) uint8 { + if d < 128 { + return uint8(fastDiv255(2 * int(d) * int(s))) + } + return uint8(255 - fastDiv255(2*(255-int(d))*(255-int(s)))) +} + +// Blend performs linear alpha blend of src over dst +// alpha <= 0 returns dst, alpha >= 1 returns src +func Blend(dst, src RGB, alpha float64) RGB { + if alpha >= 1.0 { + return src + } + if alpha <= 0.0 { + return dst + } + inv := 1.0 - alpha + return RGB{ + R: clampU8(float64(src.R)*alpha + float64(dst.R)*inv), + G: clampU8(float64(src.G)*alpha + float64(dst.G)*inv), + B: clampU8(float64(src.B)*alpha + float64(dst.B)*inv), + } +} + +// SoftLight applies Perez soft light blend, gentler than linear alpha +// intensity in [0,1] mixes between dst and the blended result +func SoftLight(dst, src RGB, intensity float64) RGB { + return RGB{ + R: softLightChannel(dst.R, src.R, intensity), + G: softLightChannel(dst.G, src.G, intensity), + B: softLightChannel(dst.B, src.B, intensity), + } +} + +// Max returns per-channel maximum, alpha-blended over dst +func Max(dst, src RGB, alpha float64) RGB { + if alpha <= 0.0 { + return dst + } + maxed := RGB{ + R: max(dst.R, src.R), + G: max(dst.G, src.G), + B: max(dst.B, src.B), + } + if alpha >= 1.0 { + return maxed + } + return Blend(dst, maxed, alpha) +} + +// Add performs saturating additive blend, alpha-blended over dst +func Add(dst, src RGB, alpha float64) RGB { + if alpha <= 0.0 { + return dst + } + added := RGB{ + R: addU8(dst.R, src.R), + G: addU8(dst.G, src.G), + B: addU8(dst.B, src.B), + } + if alpha >= 1.0 { + return added + } + return Blend(dst, added, alpha) +} + +// Screen applies 1-(1-dst)*(1-src), alpha-blended over dst +// Always lightens; useful for glow accumulation without clipping harshness of Add +func Screen(dst, src RGB, alpha float64) RGB { + if alpha <= 0.0 { + return dst + } + screened := RGB{ + R: uint8(255 - fastDiv255((255-int(dst.R))*(255-int(src.R)))), + G: uint8(255 - fastDiv255((255-int(dst.G))*(255-int(src.G)))), + B: uint8(255 - fastDiv255((255-int(dst.B))*(255-int(src.B)))), + } + if alpha >= 1.0 { + return screened + } + return Blend(dst, screened, alpha) +} + +// Overlay combines multiply (darks) and screen (lights), alpha-blended over dst +func Overlay(dst, src RGB, alpha float64) RGB { + if alpha <= 0.0 { + return dst + } + overlaid := RGB{ + R: overlayChannel(dst.R, src.R), + G: overlayChannel(dst.G, src.G), + B: overlayChannel(dst.B, src.B), + } + if alpha >= 1.0 { + return overlaid + } + return Blend(dst, overlaid, alpha) +} + +// Scale multiplies all channels by factor, saturating (factor > 1.0 brightens) +func Scale(c RGB, factor float64) RGB { + return RGB{ + R: clampU8(float64(c.R) * factor), + G: clampU8(float64(c.G) * factor), + B: clampU8(float64(c.B) * factor), + } +} + +// Grayscale converts to grayscale using Rec. 601 luma coefficients +func Grayscale(c RGB) RGB { + // CHANGED: shared with Luma + g := Luma(c) + return RGB{R: g, G: g, B: g} +} diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..69d022f --- /dev/null +++ b/doc.go @@ -0,0 +1,28 @@ +// Package color provides 24-bit RGB values, perceptual metrics, and blend +// operations, independent of any output device. +// +// RGB is a plain 3-byte value with toml field tags, safe for config binding +// and for embedding in dense cell or pixel buffers. It satisfies +// image/color.Color, so values pass directly into image, draw, and GUI +// toolkit pipelines without conversion. +// +// Device concerns — terminal capability detection, xterm-256 quantization, +// SGR emission, framebuffer formats — belong in the consuming package. +// +// # Operations +// +// Blend linear alpha compositing +// SoftLight Perez soft light, gentler than linear alpha +// Overlay multiply on darks, screen on lights +// Screen always lightens, avoids the clipping harshness of Add +// Add saturating additive +// Max per-channel maximum +// Scale channel multiply, saturating +// Grayscale Rec. 601 luma +// Lerp linear interpolation +// +// # Concurrency +// +// All values are immutable and all operations are pure. Package-level palette +// variables are writable by the language but are read-only by contract. +package color diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5ec4b21 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/lixenwraith/color + +go 1.24.0 diff --git a/palette.go b/palette.go new file mode 100644 index 0000000..8f3efe6 --- /dev/null +++ b/palette.go @@ -0,0 +1,163 @@ +package color + +// Named 24-bit palette. Pure RGB definitions, no device or domain semantics. +// Consumers alias these in their own parameter files. +// +// Naming: standard color names where RGB closely matches (CSS, X11, +// Pantone-adjacent), descriptive compound names otherwise. Ordered dark-to-light +// within each hue group. +// +// Read-only by contract. + +var ( + // --- Achromatic --- + Black = RGB{0, 0, 0} + Charcoal = RGB{5, 5, 5} + Obsidian = RGB{20, 20, 30} // Blue-black + Gunmetal = RGB{26, 27, 38} // Blue-tinted near-black + DarkSlate = RGB{35, 36, 48} // Blue-gray near-black + DimGray = RGB{55, 55, 55} + DarkGray = RGB{60, 60, 60} + IronGray = RGB{80, 80, 80} + SlateGray = RGB{80, 80, 90} // Cool-tinted + Taupe = RGB{100, 95, 85} // Warm gray + Gray = RGB{120, 120, 120} + MidGray = RGB{128, 128, 128} + CoolSilver = RGB{140, 145, 155} // Blue-tinted silver + DimSilver = RGB{155, 155, 155} + Silver = RGB{180, 180, 180} + LightGray = RGB{200, 200, 200} + NearWhite = RGB{250, 250, 250} + White = RGB{255, 255, 255} + + // --- Brown / Earth --- + Chocolate = RGB{90, 25, 15} + SaddleBrown = RGB{101, 67, 33} + DarkRust = RGB{140, 35, 25} + Sienna = RGB{140, 60, 0} + DarkPlum = RGB{60, 30, 40} // Warm dark purple-brown + BlueCharcoal = RGB{40, 45, 60} // Cool dark blue-gray + + // --- Red --- + BlackRed = RGB{50, 15, 15} + Oxblood = RGB{100, 20, 20} + DarkBurgundy = RGB{100, 25, 20} + DarkCrimson = RGB{139, 0, 0} + Brick = RGB{180, 40, 40} + Cinnabar = RGB{200, 60, 50} + IndianRed = RGB{180, 60, 60} + BurntSienna = RGB{200, 60, 25} + Vermilion = RGB{227, 66, 82} + Red = RGB{255, 0, 0} + BrightRed = RGB{255, 60, 60} + Coral = RGB{255, 80, 80} + Salmon = RGB{255, 100, 100} + LightCoral = RGB{255, 140, 140} + LightRose = RGB{255, 150, 150} + MistyRose = RGB{255, 200, 200} + + // --- Orange --- + DarkAmber = RGB{60, 40, 0} + Rust = RGB{180, 60, 20} + Amber = RGB{180, 120, 0} + Bronze = RGB{200, 100, 0} + BurntOrange = RGB{200, 110, 0} + Terracotta = RGB{220, 100, 50} + FlameOrange = RGB{240, 100, 30} + OrangeRed = RGB{255, 69, 0} + RedOrange = RGB{255, 80, 40} + Mango = RGB{255, 120, 50} + TigerOrange = RGB{255, 140, 0} + WarmOrange = RGB{255, 140, 40} + Apricot = RGB{255, 160, 60} + Orange = RGB{255, 165, 0} + + // --- Yellow --- + DarkGold = RGB{200, 150, 0} + OliveYellow = RGB{200, 180, 60} + Gold = RGB{255, 215, 0} + LemonYellow = RGB{255, 240, 60} + Yellow = RGB{255, 255, 0} + PaleGold = RGB{255, 200, 100} + Buttercream = RGB{255, 250, 150} + PaleLemon = RGB{255, 255, 100} + Ivory = RGB{255, 255, 220} + Cream = RGB{255, 255, 200} + + // --- Green --- + BlackGreen = RGB{0, 40, 0} + DarkFern = RGB{30, 80, 25} + DeepForest = RGB{25, 80, 35} + HunterGreen = RGB{35, 90, 30} + DarkGreen = RGB{15, 130, 15} + ForestGreen = RGB{34, 139, 34} + MediumGreen = RGB{40, 150, 40} + FernGreen = RGB{50, 140, 45} + LeafGreen = RGB{60, 160, 60} + SeaGreen = RGB{60, 180, 80} + SageGreen = RGB{70, 170, 100} + GrassGreen = RGB{70, 180, 55} + EmeraldGreen = RGB{60, 220, 100} + MintGreen = RGB{100, 220, 130} + BrightGreen = RGB{20, 200, 20} + YellowGreen = RGB{100, 220, 80} + LimeGreen = RGB{50, 205, 50} + NeonGreen = RGB{50, 255, 50} + BrightLime = RGB{120, 255, 80} + Lime = RGB{0, 255, 0} + LightGreen = RGB{144, 238, 144} + PaleGreen = RGB{120, 255, 120} + PastelGreen = RGB{100, 220, 100} + PaleMint = RGB{150, 255, 180} + Honeydew = RGB{200, 255, 200} + + // --- Cyan / Teal --- + Teal = RGB{0, 139, 139} + DimCyan = RGB{0, 160, 160} + VibrantCyan = RGB{0, 200, 200} + DarkTurquoise = RGB{0, 206, 209} + BrightCyan = RGB{0, 220, 220} + Cyan = RGB{0, 255, 255} + SkyTeal = RGB{80, 200, 220} + PaleCyan = RGB{200, 255, 255} + AliceBlue = RGB{230, 245, 255} + IceCyan = RGB{240, 255, 255} + + // --- Blue --- + DeepNavy = RGB{15, 25, 50} + DeepIndigo = RGB{40, 0, 180} + NavyBlue = RGB{30, 60, 120} + CobaltBlue = RGB{50, 80, 200} + SteelBlue = RGB{60, 100, 180} + MediumBlue = RGB{60, 120, 200} + RoyalBlue = RGB{65, 105, 225} + CeruleanBlue = RGB{80, 140, 220} + Cornflower = RGB{80, 130, 255} + DodgerBlue = RGB{40, 180, 255} + LightBlue = RGB{120, 170, 255} + LightSkyBlue = RGB{135, 206, 250} + BabyBlue = RGB{160, 210, 255} + Blue = RGB{0, 0, 255} + + // --- Purple / Violet --- + DeepPurple = RGB{60, 20, 80} + DarkViolet = RGB{120, 40, 180} + MutedPurple = RGB{160, 100, 160} + MediumPurple = RGB{170, 100, 210} + ElectricViolet = RGB{180, 130, 255} + LightOrchid = RGB{200, 130, 210} + Orchid = RGB{200, 120, 220} + PaleVioletRed = RGB{219, 112, 147} + SoftLavender = RGB{220, 150, 230} + PaleLavender = RGB{220, 180, 255} + + // --- Pink / Rose --- + RoseRed = RGB{255, 60, 120} + HotMagenta = RGB{255, 60, 200} + HotPink = RGB{255, 140, 200} + PalePink = RGB{255, 145, 220} + LightPink = RGB{255, 182, 193} + Pink = RGB{255, 192, 203} + Magenta = RGB{255, 0, 255} +) +` diff --git a/rgb.go b/rgb.go new file mode 100644 index 0000000..a470da1 --- /dev/null +++ b/rgb.go @@ -0,0 +1,136 @@ +package color + +import ( + "errors" + stdcolor "image/color" +) + +// RGB represents a 24-bit color +type RGB struct { + R uint8 `toml:"r"` + G uint8 `toml:"g"` + B uint8 `toml:"b"` +} + +var _ stdcolor.Color = RGB{} + +// RGBA implements image/color.Color. Alpha is always opaque, so the +// premultiplied result equals the non-premultiplied one. +func (c RGB) RGBA() (r, g, b, a uint32) { + return uint32(c.R) * 0x101, uint32(c.G) * 0x101, uint32(c.B) * 0x101, 0xffff +} + +// From converts any image/color.Color to RGB, un-premultiplying by alpha and +// discarding it. Fully transparent input yields Black. +func From(c stdcolor.Color) RGB { + r, g, b, a := c.RGBA() + switch a { + case 0: + return RGB{} + case 0xffff: + return RGB{R: uint8(r >> 8), G: uint8(g >> 8), B: uint8(b >> 8)} + } + // r,g,b <= a <= 0xffff, so r*0xffff <= 0xfffe0001 and stays in uint32 + return RGB{ + R: uint8(r * 0xffff / a >> 8), + G: uint8(g * 0xffff / a >> 8), + B: uint8(b * 0xffff / a >> 8), + } +} + +// Lerp linearly interpolates from c to other by t, clamped to [0,1] +func (c RGB) Lerp(other RGB, t float64) RGB { + if t <= 0 { + return c + } + if t >= 1 { + return other + } + return RGB{ + R: clampU8(float64(c.R) + (float64(other.R)-float64(c.R))*t), + G: clampU8(float64(c.G) + (float64(other.G)-float64(c.G))*t), + B: clampU8(float64(c.B) + (float64(other.B)-float64(c.B))*t), + } +} + +// Luma returns Rec. 601 luminance: R*0.299 + G*0.587 + B*0.114 +func Luma(c RGB) uint8 { + return uint8((int(c.R)*299 + int(c.G)*587 + int(c.B)*114) / 1000) +} + +// RedmeanDistance returns squared perceptually-weighted distance between a and b. +// Monotonic in perceived difference; use for nearest-color search, not as an +// absolute metric. +// Formula: https://en.wikipedia.org/wiki/Color_difference#sRGB +func RedmeanDistance(a, b RGB) int { + rmean := (int(a.R) + int(b.R)) / 2 + dr := int(a.R) - int(b.R) + dg := int(a.G) - int(b.G) + db := int(a.B) - int(b.B) + return (((512 + rmean) * dr * dr) >> 8) + 4*dg*dg + (((767 - rmean) * db * db) >> 8) +} + +// Hex returns the color as "#rrggbb" +func (c RGB) Hex() string { + const d = "0123456789abcdef" + b := [7]byte{ + '#', + d[c.R>>4], d[c.R&0xf], + d[c.G>>4], d[c.G&0xf], + d[c.B>>4], d[c.B&0xf], + } + return string(b[:]) +} + +var errHex = errors.New("color: invalid hex string") + +// ParseHex accepts "#rgb", "#rrggbb", and the same forms without the leading '#' +func ParseHex(s string) (RGB, error) { + if len(s) > 0 && s[0] == '#' { + s = s[1:] + } + switch len(s) { + case 3: + r, ok0 := nibble(s[0]) + g, ok1 := nibble(s[1]) + b, ok2 := nibble(s[2]) + if !ok0 || !ok1 || !ok2 { + return RGB{}, errHex + } + return RGB{R: r * 0x11, G: g * 0x11, B: b * 0x11}, nil + case 6: + var v [3]uint8 + for i := range v { + hi, ok0 := nibble(s[i*2]) + lo, ok1 := nibble(s[i*2+1]) + if !ok0 || !ok1 { + return RGB{}, errHex + } + v[i] = hi<<4 | lo + } + return RGB{R: v[0], G: v[1], B: v[2]}, nil + } + return RGB{}, errHex +} + +// MustParseHex panics on invalid input; for package-level initializers +func MustParseHex(s string) RGB { + c, err := ParseHex(s) + if err != nil { + panic(err) + } + return c +} + +func nibble(b byte) (uint8, bool) { + switch { + case b >= '0' && b <= '9': + return b - '0', true + case b >= 'a' && b <= 'f': + return b - 'a' + 10, true + case b >= 'A' && b <= 'F': + return b - 'A' + 10, true + } + return 0, false +} +`