unmagic-color 0.2.1 → 0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +26 -0
- data/README.md +2 -0
- data/lib/unmagic/color/console/help.rb +11 -26
- data/lib/unmagic/color/gradient/bitmap.rb +21 -0
- data/lib/unmagic/color/harmony.rb +188 -2
- data/lib/unmagic/color/oklch.rb +99 -17
- data/lib/unmagic/color/rgb.rb +33 -11
- data/lib/unmagic/color/version.rb +1 -1
- metadata +6 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8e8aee8c90b0b846c5352c1a8c2f9638fdbceaa830a8b240805c093326bd0a5e
|
|
4
|
+
data.tar.gz: febb8693169a657fd975d9d60e69232a291aa1f1a8a3cd05372f279a008cbe9d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d6df173651d743f7e4696d22334afaedcac56160e4ec579e2a00c2d06b80802724fd2f3e6da36cd05970994aa14e4ed0466fe33bf1f188b98f291385ed351a0a
|
|
7
|
+
data.tar.gz: 46b517537f8393d29c26567daebbfd83913d8d7cd56766f7caa7d35b048161e9c8977865bd9d36ca75669d8296179290fcf2d38c4e1c58780740707158e3ee68
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.3] - 2026-05-17
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
#### Perceptual Tonal Scales
|
|
15
|
+
- `scale(steps:, lightness:, chroma:, hue_shift:, anchor:, gamut:)` - generates perceptually-uniform tonal palettes in OKLCH, controlling lightness, chroma, and hue independently
|
|
16
|
+
- An 11-step scale anchored in the middle produces a Tailwind-style 50–950 palette (`scale` itself knows nothing about Tailwind)
|
|
17
|
+
- Tapered default chroma curve that rises into the mid-tones and eases off near white and black, avoiding muddy lights and neon darks
|
|
18
|
+
- `anchor:` pins the source color to an exact step and builds the rest of the scale around it
|
|
19
|
+
- `lightness:`, `chroma:`, and `hue_shift:` accept ranges, arrays, or procs for full control over each curve
|
|
20
|
+
- `gamut:` gamut-maps results into sRGB by default, or returns raw wide-gamut OKLCH with `:none`
|
|
21
|
+
|
|
22
|
+
#### OKLCH Gamut Mapping
|
|
23
|
+
- `OKLCH#in_gamut?` - reports whether a color is displayable within the sRGB gamut
|
|
24
|
+
- `OKLCH#clamp_to_gamut` - pulls an out-of-gamut color into sRGB by reducing chroma while holding lightness and hue fixed (perceptually correct, unlike per-channel RGB clipping)
|
|
25
|
+
- `OKLCH#to_oklab` - converts to the cartesian OKLab color space
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
- Rainbow gradient rendering in the WebAssembly demo
|
|
29
|
+
|
|
30
|
+
## [0.2.2] - 2026-01-05
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
- `Gradient::Bitmap#to_ansi` method to render gradients as colored blocks in terminal
|
|
34
|
+
|
|
10
35
|
## [0.2.1] - 2026-01-05
|
|
11
36
|
|
|
12
37
|
### Added
|
|
@@ -81,6 +106,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
81
106
|
- HSL color progressions for palette generation
|
|
82
107
|
- Multiple hash functions for string-to-color derivation
|
|
83
108
|
|
|
109
|
+
[0.2.2]: https://github.com/unreasonable-magic/unmagic-color/releases/tag/v0.2.2
|
|
84
110
|
[0.2.1]: https://github.com/unreasonable-magic/unmagic-color/releases/tag/v0.2.1
|
|
85
111
|
[0.2.0]: https://github.com/unreasonable-magic/unmagic-color/releases/tag/v0.2.0
|
|
86
112
|
[0.1.0]: https://github.com/unreasonable-magic/unmagic-color/releases/tag/v0.1.0
|
data/README.md
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
A comprehensive Ruby color manipulation library with support for RGB, HSL, and OKLCH color spaces. Parse, convert, and manipulate colors with an intuitive API.
|
|
6
6
|
|
|
7
|
+
We have a fancy-pants WASM based demo here: https://unreasonable-magic.github.io/unmagic-color/
|
|
8
|
+
|
|
7
9
|
## Installation
|
|
8
10
|
|
|
9
11
|
Add this line to your application's Gemfile:
|
|
@@ -17,38 +17,23 @@ module Unmagic
|
|
|
17
17
|
link = highlighter.link("https://github.com/unreasonable-magic/unmagic-color")
|
|
18
18
|
|
|
19
19
|
code = highlighter.highlight(<<~RUBY)
|
|
20
|
-
# Parse
|
|
20
|
+
# Parse a color (hex, rgb, hsl, oklch, ansi, css named, x11)
|
|
21
21
|
parse("#ff5733")
|
|
22
|
-
|
|
22
|
+
parse("goldenrod")
|
|
23
|
+
|
|
24
|
+
# Manually create colors
|
|
25
|
+
rgb(255, 87, 51, alpha: percentage(50))
|
|
23
26
|
hsl(9, 100, 60)
|
|
24
27
|
oklch(0.65, 0.22, 30)
|
|
25
|
-
parse("rebeccapurple")
|
|
26
|
-
|
|
27
|
-
# Manipulate colors
|
|
28
|
-
color = parse("#ff5733")
|
|
29
|
-
color.lighten(0.1)
|
|
30
|
-
color.darken(0.1)
|
|
31
|
-
color.saturate(0.1)
|
|
32
|
-
color.desaturate(0.1)
|
|
33
|
-
color.rotate(30)
|
|
34
28
|
|
|
35
|
-
#
|
|
36
|
-
|
|
37
|
-
color.to_hsl
|
|
38
|
-
color.to_oklch
|
|
39
|
-
color.to_hex
|
|
40
|
-
color.to_css_oklch
|
|
29
|
+
# Show a color card
|
|
30
|
+
show("#ff5733")
|
|
41
31
|
|
|
42
|
-
#
|
|
43
|
-
gradient(:linear, [
|
|
32
|
+
# Make a rainbow
|
|
33
|
+
puts gradient(:linear, %w[red orange yellow green blue purple], direction: "to right").rasterize(width: 60).to_ansi
|
|
44
34
|
|
|
45
|
-
#
|
|
46
|
-
|
|
47
|
-
hsl(9, 100, 60)
|
|
48
|
-
oklch(0.65, 0.22, 30)
|
|
49
|
-
parse("#ff5733")
|
|
50
|
-
gradient(:linear, ["#FF0000", "#0000FF"])
|
|
51
|
-
percentage(50)
|
|
35
|
+
# Generate Tailwind color scales
|
|
36
|
+
puts gradient(:linear, parse("#3b82f6").scale(steps: 11, anchor: 5), direction: "to right").rasterize(width: 60).to_ansi
|
|
52
37
|
RUBY
|
|
53
38
|
|
|
54
39
|
"#{link}\n\n#{code}"
|
|
@@ -85,6 +85,27 @@ module Unmagic
|
|
|
85
85
|
def to_a
|
|
86
86
|
@pixels.flatten
|
|
87
87
|
end
|
|
88
|
+
|
|
89
|
+
# Convert to ANSI escape codes for terminal display.
|
|
90
|
+
#
|
|
91
|
+
# Renders each pixel as a colored character using 24-bit true color
|
|
92
|
+
# ANSI codes. Each row is joined and rows are separated by newlines.
|
|
93
|
+
#
|
|
94
|
+
# @param fill [String] Character to use for each pixel (default: "█")
|
|
95
|
+
# @return [String] ANSI-colored string representation
|
|
96
|
+
#
|
|
97
|
+
# @example Render a rainbow gradient
|
|
98
|
+
# gradient = Gradient.linear(%w[red yellow green blue])
|
|
99
|
+
# bitmap = gradient.rasterize(width: 40)
|
|
100
|
+
# puts bitmap.to_ansi
|
|
101
|
+
#
|
|
102
|
+
# @example Use custom fill character
|
|
103
|
+
# puts bitmap.to_ansi(fill: "▀")
|
|
104
|
+
def to_ansi(fill: "█")
|
|
105
|
+
@pixels.map do |row|
|
|
106
|
+
row.map { |color| "\e[#{color.to_ansi}m#{fill}\e[0m" }.join
|
|
107
|
+
end.join("\n")
|
|
108
|
+
end
|
|
88
109
|
end
|
|
89
110
|
end
|
|
90
111
|
end
|
|
@@ -5,8 +5,9 @@ module Unmagic
|
|
|
5
5
|
# Color harmony and variations module.
|
|
6
6
|
#
|
|
7
7
|
# Provides methods for generating harmonious color palettes based on
|
|
8
|
-
# color theory principles.
|
|
9
|
-
# for accurate hue-based
|
|
8
|
+
# color theory principles. Harmony and the {#shades}/{#tints}/{#tones}
|
|
9
|
+
# variations are computed in HSL color space for accurate hue-based
|
|
10
|
+
# relationships; {#scale} is computed in OKLCH for perceptual uniformity.
|
|
10
11
|
#
|
|
11
12
|
# Included in the base Color class, making these methods available to
|
|
12
13
|
# RGB, HSL, and OKLCH color spaces via inheritance.
|
|
@@ -40,6 +41,25 @@ module Unmagic
|
|
|
40
41
|
# blue.shades(steps: 3) # => [darker1, darker2, darker3]
|
|
41
42
|
# blue.tints(steps: 3) # => [lighter1, lighter2, lighter3]
|
|
42
43
|
module Harmony
|
|
44
|
+
# Default lightness curve for {#scale}: 11 control points describing the
|
|
45
|
+
# *shape* of the light→dark sweep (1.0 = lightest, 0.0 = darkest), sampled
|
|
46
|
+
# with linear interpolation. Derived from the average of several
|
|
47
|
+
# hand-tuned production color ramps.
|
|
48
|
+
SCALE_LIGHTNESS_SHAPE = [
|
|
49
|
+
1.0, 0.948, 0.876, 0.769, 0.622, 0.514, 0.416, 0.323, 0.234, 0.168, 0.0,
|
|
50
|
+
].freeze
|
|
51
|
+
|
|
52
|
+
# Default chroma curve for {#scale}: 11 control points (peak normalized
|
|
53
|
+
# to 1.0). Chroma rises into the mid-tones and tapers toward both ends —
|
|
54
|
+
# a constant chroma reads as muddy near white and neon near black, and
|
|
55
|
+
# the sRGB gamut itself narrows at the extremes.
|
|
56
|
+
SCALE_CHROMA_CURVE = [
|
|
57
|
+
0.055, 0.131, 0.247, 0.447, 0.727, 0.920, 1.0, 0.931, 0.767, 0.586, 0.374,
|
|
58
|
+
].freeze
|
|
59
|
+
|
|
60
|
+
# Default lightness endpoints `[lightest, darkest]` for {#scale}.
|
|
61
|
+
SCALE_LIGHTNESS_DEFAULT = [0.971, 0.270].freeze
|
|
62
|
+
|
|
43
63
|
# Returns the complementary color (180° opposite on the color wheel).
|
|
44
64
|
#
|
|
45
65
|
# Complementary colors create high contrast and visual tension.
|
|
@@ -260,8 +280,174 @@ module Unmagic
|
|
|
260
280
|
end
|
|
261
281
|
end
|
|
262
282
|
|
|
283
|
+
# Generate a perceptually-uniform tonal scale from this color.
|
|
284
|
+
#
|
|
285
|
+
# Produces an ordered sequence of colors from light to dark, computed in
|
|
286
|
+
# the OKLCH color space so each step sits an even perceptual distance
|
|
287
|
+
# from the last. Unlike {#shades}/{#tints} (which blend toward black or
|
|
288
|
+
# white in HSL), `scale` controls lightness, chroma, and hue
|
|
289
|
+
# independently and gamut-maps every result.
|
|
290
|
+
#
|
|
291
|
+
# The chroma curve is the important part: chroma rises into the
|
|
292
|
+
# mid-tones and tapers toward both ends, because a constant chroma reads
|
|
293
|
+
# as muddy near white and as neon near black, and because the sRGB gamut
|
|
294
|
+
# itself narrows at the extremes.
|
|
295
|
+
#
|
|
296
|
+
# This is a general-purpose primitive. An 11-step scale anchored in the
|
|
297
|
+
# middle happens to produce a Tailwind-style 50–950 palette, but the
|
|
298
|
+
# method itself knows nothing about Tailwind.
|
|
299
|
+
#
|
|
300
|
+
# @param steps [Integer] Number of colors to generate (must be at least 2)
|
|
301
|
+
# @param lightness [Range, Array<Numeric>, Proc, nil] OKLCH lightness
|
|
302
|
+
# control. A `Range` gives the light/dark endpoints; an `Array` gives
|
|
303
|
+
# an explicit value per step; a `Proc` is called with `(t, index)`;
|
|
304
|
+
# `nil` uses the default curve.
|
|
305
|
+
# @param chroma [Symbol, Array<Numeric>, Proc] OKLCH chroma control.
|
|
306
|
+
# `:peak` (default) applies the tapered curve scaled to this color's
|
|
307
|
+
# chroma; `:flat` holds chroma constant; an `Array` or `Proc` supplies
|
|
308
|
+
# values directly.
|
|
309
|
+
# @param hue_shift [Range, Numeric, Proc, nil] Hue drift in degrees
|
|
310
|
+
# across the scale. `nil` (default) keeps the hue constant.
|
|
311
|
+
# @param anchor [Integer, nil] Index at which this color is placed
|
|
312
|
+
# exactly — its lightness, chroma, and hue are preserved at that step
|
|
313
|
+
# and the rest of the scale is built around it.
|
|
314
|
+
# @param gamut [Symbol] `:srgb` (default) gamut-maps every result into
|
|
315
|
+
# sRGB so {RGB#to_hex} is trustworthy; `:none` returns the raw OKLCH
|
|
316
|
+
# colors, which may be wider than sRGB.
|
|
317
|
+
# @return [Array<OKLCH>] `steps` colors in OKLCH, ordered light to dark
|
|
318
|
+
# @raise [ArgumentError] If steps < 2, anchor is out of range, or gamut
|
|
319
|
+
# is not :srgb or :none
|
|
320
|
+
#
|
|
321
|
+
# @example An 11-step palette anchored on the base color
|
|
322
|
+
# base = Unmagic::Color.parse("oklch(0.62 0.21 260)")
|
|
323
|
+
# palette = base.scale(steps: 11, anchor: 5)
|
|
324
|
+
#
|
|
325
|
+
# @example Label an 11-step scale as Tailwind stops
|
|
326
|
+
# stops = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950]
|
|
327
|
+
# tailwind = stops.zip(base.scale(steps: 11, anchor: 5)).to_h
|
|
328
|
+
def scale(steps: 11, lightness: nil, chroma: :peak, hue_shift: nil, anchor: nil, gamut: :srgb)
|
|
329
|
+
raise ArgumentError, "steps must be at least 2" if steps < 2
|
|
330
|
+
if anchor && !(0...steps).cover?(anchor)
|
|
331
|
+
raise ArgumentError, "anchor must be between 0 and #{steps - 1}"
|
|
332
|
+
end
|
|
333
|
+
unless [:srgb, :none].include?(gamut)
|
|
334
|
+
raise ArgumentError, "gamut must be :srgb or :none"
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
base = to_oklch
|
|
338
|
+
positions = Array.new(steps) { |i| i.fdiv(steps - 1) }
|
|
339
|
+
|
|
340
|
+
lightnesses = scale_lightness(positions, lightness, anchor, base.lightness)
|
|
341
|
+
chromas = scale_chroma(positions, chroma, anchor, base.chroma.value)
|
|
342
|
+
hues = scale_hue(positions, hue_shift, base.hue.value)
|
|
343
|
+
|
|
344
|
+
Array.new(steps) do |i|
|
|
345
|
+
color = OKLCH.new(
|
|
346
|
+
lightness: lightnesses[i].clamp(0.0, 1.0),
|
|
347
|
+
chroma: [chromas[i], 0.0].max,
|
|
348
|
+
hue: hues[i],
|
|
349
|
+
alpha: base.alpha.value,
|
|
350
|
+
)
|
|
351
|
+
gamut == :none ? color : color.clamp_to_gamut
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
|
|
263
355
|
private
|
|
264
356
|
|
|
357
|
+
# Compute the OKLCH lightness for each step of a {#scale}.
|
|
358
|
+
#
|
|
359
|
+
# @return [Array<Float>] One lightness ratio (0.0-1.0) per step
|
|
360
|
+
def scale_lightness(positions, lightness, anchor, base_lightness)
|
|
361
|
+
case lightness
|
|
362
|
+
when Array
|
|
363
|
+
positions.each_index.map { |i| lightness[i] || lightness.last }
|
|
364
|
+
when Proc
|
|
365
|
+
positions.each_index.map { |i| lightness.call(positions[i], i) }
|
|
366
|
+
else
|
|
367
|
+
light, dark = if lightness.is_a?(Range)
|
|
368
|
+
[lightness.begin.to_f, lightness.end.to_f]
|
|
369
|
+
else
|
|
370
|
+
SCALE_LIGHTNESS_DEFAULT
|
|
371
|
+
end
|
|
372
|
+
shape = positions.map { |t| interpolate_curve(SCALE_LIGHTNESS_SHAPE, t) }
|
|
373
|
+
return shape.map { |s| dark + (light - dark) * s } unless anchor
|
|
374
|
+
|
|
375
|
+
warp_lightness_to_anchor(shape, shape[anchor], base_lightness, light, dark)
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# Warp the lightness shape so it passes exactly through the base color's
|
|
380
|
+
# lightness at the anchor, keeping the curve monotonic and pinned to the
|
|
381
|
+
# light/dark endpoints. The light and dark halves are scaled separately.
|
|
382
|
+
#
|
|
383
|
+
# @return [Array<Float>] One lightness ratio per step
|
|
384
|
+
def warp_lightness_to_anchor(shape, anchor_shape, base_lightness, light, dark)
|
|
385
|
+
shape.map do |s|
|
|
386
|
+
if s >= anchor_shape
|
|
387
|
+
next base_lightness if anchor_shape >= 1.0
|
|
388
|
+
|
|
389
|
+
base_lightness + (light - base_lightness) * (s - anchor_shape) / (1.0 - anchor_shape)
|
|
390
|
+
else
|
|
391
|
+
next base_lightness unless anchor_shape.positive?
|
|
392
|
+
|
|
393
|
+
dark + (base_lightness - dark) * s / anchor_shape
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# Compute the OKLCH chroma for each step of a {#scale}.
|
|
399
|
+
#
|
|
400
|
+
# @return [Array<Float>] One chroma value per step
|
|
401
|
+
def scale_chroma(positions, chroma, anchor, base_chroma)
|
|
402
|
+
case chroma
|
|
403
|
+
when :flat
|
|
404
|
+
positions.map { base_chroma }
|
|
405
|
+
when Array
|
|
406
|
+
positions.each_index.map { |i| chroma[i] || chroma.last }
|
|
407
|
+
when Proc
|
|
408
|
+
positions.each_index.map { |i| chroma.call(positions[i], i) }
|
|
409
|
+
when :peak, nil
|
|
410
|
+
curve = positions.map { |t| interpolate_curve(SCALE_CHROMA_CURVE, t) }
|
|
411
|
+
reference = anchor ? curve[anchor] : curve.max
|
|
412
|
+
factor = reference.zero? ? 0.0 : base_chroma / reference
|
|
413
|
+
curve.map { |c| c * factor }
|
|
414
|
+
else
|
|
415
|
+
raise ArgumentError, "chroma must be :peak, :flat, an Array, or a Proc"
|
|
416
|
+
end
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# Compute the OKLCH hue for each step of a {#scale}.
|
|
420
|
+
#
|
|
421
|
+
# @return [Array<Float>] One hue value (degrees) per step
|
|
422
|
+
def scale_hue(positions, hue_shift, base_hue)
|
|
423
|
+
case hue_shift
|
|
424
|
+
when nil
|
|
425
|
+
positions.map { base_hue }
|
|
426
|
+
when Range
|
|
427
|
+
from = hue_shift.begin.to_f
|
|
428
|
+
to = hue_shift.end.to_f
|
|
429
|
+
positions.map { |t| base_hue + from + (to - from) * t }
|
|
430
|
+
when Numeric
|
|
431
|
+
positions.map { |t| base_hue + (hue_shift * t) }
|
|
432
|
+
when Proc
|
|
433
|
+
positions.each_index.map { |i| base_hue + hue_shift.call(positions[i], i) }
|
|
434
|
+
else
|
|
435
|
+
raise ArgumentError, "hue_shift must be nil, a Range, Numeric, or a Proc"
|
|
436
|
+
end
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
# Sample an evenly-spaced control-point array at position `t` (0.0-1.0),
|
|
440
|
+
# linearly interpolating between the two nearest points.
|
|
441
|
+
#
|
|
442
|
+
# @return [Float] The interpolated value
|
|
443
|
+
def interpolate_curve(control, t)
|
|
444
|
+
t = t.clamp(0.0, 1.0)
|
|
445
|
+
x = t * (control.length - 1)
|
|
446
|
+
low = x.floor
|
|
447
|
+
high = [low + 1, control.length - 1].min
|
|
448
|
+
control[low] + (control[high] - control[low]) * (x - low)
|
|
449
|
+
end
|
|
450
|
+
|
|
265
451
|
# Rotate the hue by the specified degrees and return a new color.
|
|
266
452
|
#
|
|
267
453
|
# @param degrees [Numeric] Degrees to rotate (positive = clockwise)
|
data/lib/unmagic/color/oklch.rb
CHANGED
|
@@ -275,31 +275,82 @@ module Unmagic
|
|
|
275
275
|
to_rgb.to_hsl
|
|
276
276
|
end
|
|
277
277
|
|
|
278
|
+
# Convert to the OKLab color space as `[lightness, a, b]`.
|
|
279
|
+
#
|
|
280
|
+
# OKLab is the cartesian form of OKLCH: `a` is the green–red axis and
|
|
281
|
+
# `b` is the blue–yellow axis. The Euclidean distance between two OKLab
|
|
282
|
+
# triples is a perceptual color difference (ΔE).
|
|
283
|
+
#
|
|
284
|
+
# @return [Array<Float>] The `[L, a, b]` OKLab coordinates
|
|
285
|
+
#
|
|
286
|
+
# @example
|
|
287
|
+
# color = OKLCH.new(lightness: 0.65, chroma: 0.15, hue: 240)
|
|
288
|
+
# l, a, b = color.to_oklab
|
|
289
|
+
def to_oklab
|
|
290
|
+
h_rad = @hue.value * Math::PI / 180.0
|
|
291
|
+
c = @chroma.value
|
|
292
|
+
[@lightness.to_ratio, c * Math.cos(h_rad), c * Math.sin(h_rad)]
|
|
293
|
+
end
|
|
294
|
+
|
|
278
295
|
# Convert to RGB color space.
|
|
279
296
|
#
|
|
280
|
-
#
|
|
281
|
-
#
|
|
282
|
-
#
|
|
297
|
+
# Uses the full OKLab color-science pipeline: OKLCH → OKLab → linear
|
|
298
|
+
# sRGB → gamma-encoded sRGB. Colors outside the sRGB gamut are clipped
|
|
299
|
+
# per channel; call {#clamp_to_gamut} first for a perceptual fit.
|
|
300
|
+
#
|
|
301
|
+
# @return [RGB] The color in RGB color space
|
|
283
302
|
def to_rgb
|
|
284
|
-
# For now, convert via approximation - would need proper OKLCH->sRGB conversion
|
|
285
|
-
# This is a simplified placeholder that approximates RGB from OKLCH
|
|
286
303
|
require_relative "rgb"
|
|
287
304
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
305
|
+
r, g, b = to_linear_srgb.map { |channel| linear_to_srgb(channel) }
|
|
306
|
+
Unmagic::Color::RGB.new(
|
|
307
|
+
red: (r * 255).round.clamp(0, 255),
|
|
308
|
+
green: (g * 255).round.clamp(0, 255),
|
|
309
|
+
blue: (b * 255).round.clamp(0, 255),
|
|
310
|
+
alpha: @alpha,
|
|
311
|
+
)
|
|
312
|
+
end
|
|
291
313
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
314
|
+
# Whether this color can be displayed in the sRGB gamut.
|
|
315
|
+
#
|
|
316
|
+
# OKLCH can describe colors no sRGB monitor can show — typically
|
|
317
|
+
# high-chroma colors near very light or very dark lightness, where the
|
|
318
|
+
# sRGB gamut narrows. This returns false for those.
|
|
319
|
+
#
|
|
320
|
+
# @param epsilon [Float] Tolerance for channels sitting just past 0 or 1
|
|
321
|
+
# @return [Boolean] true if every linear sRGB channel falls within [0, 1]
|
|
322
|
+
def in_gamut?(epsilon = 1e-4)
|
|
323
|
+
to_linear_srgb.all? { |channel| channel >= -epsilon && channel <= 1.0 + epsilon }
|
|
324
|
+
end
|
|
297
325
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
326
|
+
# Pull this color into the sRGB gamut by reducing chroma.
|
|
327
|
+
#
|
|
328
|
+
# Holds lightness and hue fixed and binary-searches chroma downward
|
|
329
|
+
# until the color is displayable. An already-displayable color is
|
|
330
|
+
# returned unchanged. This is the perceptually correct way to map an
|
|
331
|
+
# out-of-gamut OKLCH color — clipping RGB channels instead shifts hue.
|
|
332
|
+
#
|
|
333
|
+
# @return [OKLCH] An in-gamut color with the same lightness and hue
|
|
334
|
+
#
|
|
335
|
+
# @example
|
|
336
|
+
# vivid = OKLCH.new(lightness: 0.95, chroma: 0.3, hue: 140)
|
|
337
|
+
# vivid.clamp_to_gamut # => a displayable, lower-chroma green
|
|
338
|
+
def clamp_to_gamut
|
|
339
|
+
return self if in_gamut?
|
|
340
|
+
|
|
341
|
+
lo = 0.0
|
|
342
|
+
hi = @chroma.value
|
|
343
|
+
20.times do
|
|
344
|
+
mid = (lo + hi) / 2.0
|
|
345
|
+
candidate = self.class.new(lightness: @lightness.to_ratio, chroma: mid, hue: @hue.value, alpha: @alpha.value)
|
|
346
|
+
if candidate.in_gamut?
|
|
347
|
+
lo = mid
|
|
348
|
+
else
|
|
349
|
+
hi = mid
|
|
350
|
+
end
|
|
351
|
+
end
|
|
301
352
|
|
|
302
|
-
|
|
353
|
+
self.class.new(lightness: @lightness.to_ratio, chroma: lo, hue: @hue.value, alpha: @alpha.value)
|
|
303
354
|
end
|
|
304
355
|
|
|
305
356
|
# Convert to hex string.
|
|
@@ -540,6 +591,37 @@ module Unmagic
|
|
|
540
591
|
def clamp01(x)
|
|
541
592
|
x.clamp(0.0, 1.0)
|
|
542
593
|
end
|
|
594
|
+
|
|
595
|
+
# OKLCH → OKLab → linear sRGB. Channels may fall outside [0, 1] when the
|
|
596
|
+
# color is out of gamut; callers clip or gamut-map as needed.
|
|
597
|
+
#
|
|
598
|
+
# @return [Array<Float>] Linear (non-gamma) sRGB channels
|
|
599
|
+
def to_linear_srgb
|
|
600
|
+
l, a, b = to_oklab
|
|
601
|
+
|
|
602
|
+
l_ = ((l + (0.3963377774 * a) + (0.2158037573 * b))**3)
|
|
603
|
+
m_ = ((l - (0.1055613458 * a) - (0.0638541728 * b))**3)
|
|
604
|
+
s_ = ((l - (0.0894841775 * a) - (1.2914855480 * b))**3)
|
|
605
|
+
|
|
606
|
+
[
|
|
607
|
+
(4.0767416621 * l_) - (3.3077115913 * m_) + (0.2309699292 * s_),
|
|
608
|
+
(-1.2684380046 * l_) + (2.6097574011 * m_) - (0.3413193965 * s_),
|
|
609
|
+
(-0.0041960863 * l_) - (0.7034186147 * m_) + (1.7076147010 * s_),
|
|
610
|
+
]
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
# Gamma-encode a single linear sRGB channel, clipping to [0, 1].
|
|
614
|
+
#
|
|
615
|
+
# @param channel [Float] A linear sRGB channel value
|
|
616
|
+
# @return [Float] The gamma-encoded sRGB channel (0.0-1.0)
|
|
617
|
+
def linear_to_srgb(channel)
|
|
618
|
+
channel = channel.clamp(0.0, 1.0)
|
|
619
|
+
if channel <= 0.0031308
|
|
620
|
+
12.92 * channel
|
|
621
|
+
else
|
|
622
|
+
(1.055 * (channel**(1 / 2.4))) - 0.055
|
|
623
|
+
end
|
|
624
|
+
end
|
|
543
625
|
end
|
|
544
626
|
end
|
|
545
627
|
end
|
data/lib/unmagic/color/rgb.rb
CHANGED
|
@@ -379,21 +379,31 @@ module Unmagic
|
|
|
379
379
|
|
|
380
380
|
# Convert to OKLCH color space.
|
|
381
381
|
#
|
|
382
|
-
# Converts this RGB color to OKLCH (Lightness, Chroma, Hue)
|
|
382
|
+
# Converts this RGB color to OKLCH (Lightness, Chroma, Hue) using the
|
|
383
|
+
# full OKLab color-science pipeline: gamma-decoded sRGB → linear sRGB →
|
|
384
|
+
# OKLab → OKLCH.
|
|
383
385
|
#
|
|
384
386
|
# @return [OKLCH] The color in OKLCH color space
|
|
385
|
-
# @note This is currently a simplified approximation.
|
|
386
387
|
def to_oklch
|
|
387
|
-
# For now, simple approximation based on RGB -> HSL -> OKLCH
|
|
388
|
-
# This is a simplified placeholder
|
|
389
388
|
require_relative "oklch"
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
389
|
+
|
|
390
|
+
r = srgb_to_linear(@red.value / 255.0)
|
|
391
|
+
g = srgb_to_linear(@green.value / 255.0)
|
|
392
|
+
b = srgb_to_linear(@blue.value / 255.0)
|
|
393
|
+
|
|
394
|
+
l = Math.cbrt((0.4122214708 * r) + (0.5363325363 * g) + (0.0514459929 * b))
|
|
395
|
+
m = Math.cbrt((0.2119034982 * r) + (0.6806995451 * g) + (0.1073969566 * b))
|
|
396
|
+
s = Math.cbrt((0.0883024619 * r) + (0.2817188376 * g) + (0.6299787005 * b))
|
|
397
|
+
|
|
398
|
+
ok_l = (0.2104542553 * l) + (0.7936177850 * m) - (0.0040720468 * s)
|
|
399
|
+
ok_a = (1.9779984951 * l) - (2.4285922050 * m) + (0.4505937099 * s)
|
|
400
|
+
ok_b = (0.0259040371 * l) + (0.7827717662 * m) - (0.8086757660 * s)
|
|
401
|
+
|
|
402
|
+
chroma = Math.sqrt((ok_a * ok_a) + (ok_b * ok_b))
|
|
403
|
+
hue = Math.atan2(ok_b, ok_a) * 180.0 / Math::PI
|
|
404
|
+
hue += 360.0 if hue.negative?
|
|
405
|
+
|
|
406
|
+
Unmagic::Color::OKLCH.new(lightness: ok_l, chroma: chroma, hue: hue, alpha: @alpha)
|
|
397
407
|
end
|
|
398
408
|
|
|
399
409
|
# Calculate the relative luminance.
|
|
@@ -549,6 +559,18 @@ module Unmagic
|
|
|
549
559
|
|
|
550
560
|
private
|
|
551
561
|
|
|
562
|
+
# Gamma-decode a single sRGB channel to linear light.
|
|
563
|
+
#
|
|
564
|
+
# @param channel [Float] An sRGB channel value (0.0-1.0)
|
|
565
|
+
# @return [Float] The linear sRGB channel value
|
|
566
|
+
def srgb_to_linear(channel)
|
|
567
|
+
if channel <= 0.04045
|
|
568
|
+
channel / 12.92
|
|
569
|
+
else
|
|
570
|
+
((channel + 0.055) / 1.055)**2.4
|
|
571
|
+
end
|
|
572
|
+
end
|
|
573
|
+
|
|
552
574
|
# Convert to ANSI true color format (24-bit RGB).
|
|
553
575
|
#
|
|
554
576
|
# @param layer [Symbol] Foreground or background layer
|
metadata
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: unmagic-color
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: '0.3'
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Keith Pitt
|
|
8
|
+
autorequire:
|
|
8
9
|
bindir: bin
|
|
9
10
|
cert_chain: []
|
|
10
|
-
date:
|
|
11
|
+
date: 2026-05-17 00:00:00.000000000 Z
|
|
11
12
|
dependencies: []
|
|
12
13
|
description: Parse, convert, and manipulate colors with support for RGB, Hex, HSL
|
|
13
14
|
formats, contrast calculations, and color blending
|
|
@@ -55,6 +56,7 @@ metadata:
|
|
|
55
56
|
homepage_uri: https://github.com/unreasonable-magic/unmagic-color
|
|
56
57
|
source_code_uri: https://github.com/unreasonable-magic/unmagic-color
|
|
57
58
|
changelog_uri: https://github.com/unreasonable-magic/unmagic-color/CHANGELOG.md
|
|
59
|
+
post_install_message:
|
|
58
60
|
rdoc_options: []
|
|
59
61
|
require_paths:
|
|
60
62
|
- lib
|
|
@@ -69,7 +71,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
69
71
|
- !ruby/object:Gem::Version
|
|
70
72
|
version: '0'
|
|
71
73
|
requirements: []
|
|
72
|
-
rubygems_version:
|
|
74
|
+
rubygems_version: 3.5.22
|
|
75
|
+
signing_key:
|
|
73
76
|
specification_version: 4
|
|
74
77
|
summary: Comprehensive color manipulation library
|
|
75
78
|
test_files: []
|