abachrome-float 0.1.6

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.
Files changed (88) hide show
  1. checksums.yaml +7 -0
  2. data/.envrc +3 -0
  3. data/.rubocop.yml +10 -0
  4. data/CHANGELOG.md +21 -0
  5. data/CLA.md +45 -0
  6. data/CODE-OF-CONDUCT.md +9 -0
  7. data/LICENSE +19 -0
  8. data/README.md +315 -0
  9. data/Rakefile +15 -0
  10. data/SECURITY.md +94 -0
  11. data/abachrome-float.gemspec +36 -0
  12. data/demos/ncurses/plasma.rb +124 -0
  13. data/devenv.lock +171 -0
  14. data/devenv.nix +52 -0
  15. data/devenv.yaml +8 -0
  16. data/lib/abachrome/color.rb +197 -0
  17. data/lib/abachrome/color_mixins/blend.rb +100 -0
  18. data/lib/abachrome/color_mixins/lighten.rb +90 -0
  19. data/lib/abachrome/color_mixins/spectral_mix.rb +70 -0
  20. data/lib/abachrome/color_mixins/to_colorspace.rb +107 -0
  21. data/lib/abachrome/color_mixins/to_grayscale.rb +87 -0
  22. data/lib/abachrome/color_mixins/to_lrgb.rb +121 -0
  23. data/lib/abachrome/color_mixins/to_oklab.rb +117 -0
  24. data/lib/abachrome/color_mixins/to_oklch.rb +110 -0
  25. data/lib/abachrome/color_mixins/to_srgb.rb +142 -0
  26. data/lib/abachrome/color_models/cmyk.rb +159 -0
  27. data/lib/abachrome/color_models/hsv.rb +49 -0
  28. data/lib/abachrome/color_models/lms.rb +38 -0
  29. data/lib/abachrome/color_models/oklab.rb +37 -0
  30. data/lib/abachrome/color_models/oklch.rb +91 -0
  31. data/lib/abachrome/color_models/rgb.rb +58 -0
  32. data/lib/abachrome/color_models/xyz.rb +31 -0
  33. data/lib/abachrome/color_models/yiq.rb +37 -0
  34. data/lib/abachrome/color_space.rb +199 -0
  35. data/lib/abachrome/converter.rb +117 -0
  36. data/lib/abachrome/converters/base.rb +128 -0
  37. data/lib/abachrome/converters/cmyk_to_srgb.rb +42 -0
  38. data/lib/abachrome/converters/lms_to_lrgb.rb +40 -0
  39. data/lib/abachrome/converters/lms_to_srgb.rb +27 -0
  40. data/lib/abachrome/converters/lms_to_xyz.rb +34 -0
  41. data/lib/abachrome/converters/lrgb_to_lms.rb +3 -0
  42. data/lib/abachrome/converters/lrgb_to_oklab.rb +57 -0
  43. data/lib/abachrome/converters/lrgb_to_srgb.rb +59 -0
  44. data/lib/abachrome/converters/lrgb_to_xyz.rb +33 -0
  45. data/lib/abachrome/converters/oklab_to_lms.rb +44 -0
  46. data/lib/abachrome/converters/oklab_to_lrgb.rb +71 -0
  47. data/lib/abachrome/converters/oklab_to_oklch.rb +56 -0
  48. data/lib/abachrome/converters/oklab_to_srgb.rb +46 -0
  49. data/lib/abachrome/converters/oklch_to_lrgb.rb +79 -0
  50. data/lib/abachrome/converters/oklch_to_oklab.rb +52 -0
  51. data/lib/abachrome/converters/oklch_to_srgb.rb +46 -0
  52. data/lib/abachrome/converters/oklch_to_xyz.rb +70 -0
  53. data/lib/abachrome/converters/srgb_to_cmyk.rb +64 -0
  54. data/lib/abachrome/converters/srgb_to_lrgb.rb +55 -0
  55. data/lib/abachrome/converters/srgb_to_oklab.rb +45 -0
  56. data/lib/abachrome/converters/srgb_to_oklch.rb +47 -0
  57. data/lib/abachrome/converters/srgb_to_yiq.rb +49 -0
  58. data/lib/abachrome/converters/xyz_to_lms.rb +34 -0
  59. data/lib/abachrome/converters/xyz_to_oklab.rb +42 -0
  60. data/lib/abachrome/converters/yiq_to_srgb.rb +47 -0
  61. data/lib/abachrome/gamut/base.rb +74 -0
  62. data/lib/abachrome/gamut/p3.rb +27 -0
  63. data/lib/abachrome/gamut/rec2020.rb +25 -0
  64. data/lib/abachrome/gamut/srgb.rb +49 -0
  65. data/lib/abachrome/illuminants/base.rb +35 -0
  66. data/lib/abachrome/illuminants/d50.rb +33 -0
  67. data/lib/abachrome/illuminants/d55.rb +29 -0
  68. data/lib/abachrome/illuminants/d65.rb +37 -0
  69. data/lib/abachrome/illuminants/d75.rb +29 -0
  70. data/lib/abachrome/named/css.rb +157 -0
  71. data/lib/abachrome/named/tailwind.rb +301 -0
  72. data/lib/abachrome/outputs/css.rb +119 -0
  73. data/lib/abachrome/palette.rb +244 -0
  74. data/lib/abachrome/palette_mixins/interpolate.rb +53 -0
  75. data/lib/abachrome/palette_mixins/resample.rb +61 -0
  76. data/lib/abachrome/palette_mixins/stretch_luminance.rb +72 -0
  77. data/lib/abachrome/parsers/css.rb +452 -0
  78. data/lib/abachrome/parsers/hex.rb +52 -0
  79. data/lib/abachrome/parsers/tailwind.rb +45 -0
  80. data/lib/abachrome/spectral.rb +276 -0
  81. data/lib/abachrome/to_abcd.rb +23 -0
  82. data/lib/abachrome/version.rb +7 -0
  83. data/lib/abachrome.rb +242 -0
  84. data/logo.png +0 -0
  85. data/logo.webp +0 -0
  86. data/security/assesments/2025-10-12-SECURITY_ASSESSMENT.md +53 -0
  87. data/security/vex.json +21 -0
  88. metadata +146 -0
@@ -0,0 +1,452 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # Abachrome::Parsers::CSS - CSS color format parser
5
+ #
6
+ # This parser handles various CSS color formats including:
7
+ # - Named colors (red, blue, etc.)
8
+ # - Hex colors (#rgb, #rrggbb, #rgba, #rrggbbaa)
9
+ # - rgb() and rgba() functions
10
+ # - hsl() and hsla() functions
11
+ # - hwb() function
12
+ # - lab() and lch() functions
13
+ # - oklab() and oklch() functions
14
+ # - color() function
15
+ #
16
+
17
+ require_relative "hex"
18
+ require_relative "../named/css"
19
+ require_relative "../color"
20
+
21
+ module Abachrome
22
+ module Parsers
23
+ class CSS
24
+ def self.parse(input)
25
+ return nil unless input.is_a?(String)
26
+
27
+ input = input.strip.downcase
28
+
29
+ # Try named colors first
30
+ named_color = parse_named_color(input)
31
+ return named_color if named_color
32
+
33
+ # Try hex colors
34
+ hex_color = Hex.parse(input)
35
+ return hex_color if hex_color
36
+
37
+ # Try functional notation
38
+ parse_functional_color(input)
39
+ end
40
+
41
+ def self.parse_named_color(input)
42
+ # Check if input matches a named color
43
+ rgb_values = Named::CSS.method(input.to_sym)&.call
44
+ return nil unless rgb_values
45
+
46
+ # Convert 0-255 RGB values to 0-1 range
47
+ r, g, b = rgb_values.map { |v| v / 255.0 }
48
+ Color.from_rgb(r, g, b)
49
+ rescue NameError
50
+ nil
51
+ end
52
+
53
+ def self.parse_functional_color(input)
54
+ case input
55
+ when /^rgb\((.+)\)$/
56
+ parse_rgb(::Regexp.last_match(1))
57
+ when /^rgba\((.+)\)$/
58
+ parse_rgba(::Regexp.last_match(1))
59
+ when /^hsl\((.+)\)$/
60
+ parse_hsl(::Regexp.last_match(1))
61
+ when /^hsla\((.+)\)$/
62
+ parse_hsla(::Regexp.last_match(1))
63
+ when /^hwb\((.+)\)$/
64
+ parse_hwb(::Regexp.last_match(1))
65
+ when /^lab\((.+)\)$/
66
+ parse_lab(::Regexp.last_match(1))
67
+ when /^lch\((.+)\)$/
68
+ parse_lch(::Regexp.last_match(1))
69
+ when /^oklab\((.+)\)$/
70
+ parse_oklab(::Regexp.last_match(1))
71
+ when /^oklch\((.+)\)$/
72
+ parse_oklch(::Regexp.last_match(1))
73
+ when /^color\((.+)\)$/
74
+ parse_color_function(::Regexp.last_match(1))
75
+ end
76
+ end
77
+
78
+ def self.parse_rgb(params)
79
+ values = parse_color_values(params, 3)
80
+ return nil unless values
81
+
82
+ r, g, b = values
83
+ Color.from_rgb(r, g, b)
84
+ end
85
+
86
+ def self.parse_rgba(params)
87
+ values = parse_color_values(params, 4)
88
+ return nil unless values
89
+
90
+ r, g, b, a = values
91
+ Color.from_rgb(r, g, b, a)
92
+ end
93
+
94
+ def self.parse_hsl(params)
95
+ values = parse_hsl_values(params, 3)
96
+ return nil unless values
97
+
98
+ h, s, l = values
99
+ rgb = hsl_to_rgb(h, s, l)
100
+ Color.from_rgb(*rgb)
101
+ end
102
+
103
+ def self.parse_hsla(params)
104
+ values = parse_hsl_values(params, 4)
105
+ return nil unless values
106
+
107
+ h, s, l, a = values
108
+ rgb = hsl_to_rgb(h, s, l)
109
+ Color.from_rgb(*rgb, a)
110
+ end
111
+
112
+ def self.parse_hwb(params)
113
+ values = parse_hwb_values(params)
114
+ return nil unless values
115
+
116
+ h, w, b, a = values
117
+ rgb = hwb_to_rgb(h, w, b)
118
+ Color.from_rgb(*rgb, a)
119
+ end
120
+
121
+ def self.parse_lab(params)
122
+ values = parse_lab_values(params, 3)
123
+ return nil unless values
124
+
125
+ l, a, b = values
126
+ # Convert CIELAB to XYZ, then to sRGB
127
+ xyz = lab_to_xyz(l, a, b)
128
+ rgb = xyz_to_rgb(*xyz)
129
+ Color.from_rgb(*rgb)
130
+ end
131
+
132
+ def self.parse_lch(params)
133
+ values = parse_lch_values(params, 3)
134
+ return nil unless values
135
+
136
+ l, c, h = values
137
+ # Convert CIELCH to CIELAB, then to XYZ, then to sRGB
138
+ lab = lch_to_lab(l, c, h)
139
+ xyz = lab_to_xyz(*lab)
140
+ rgb = xyz_to_rgb(*xyz)
141
+ Color.from_rgb(*rgb)
142
+ end
143
+
144
+ def self.parse_oklab(params)
145
+ values = parse_oklab_values(params, 3)
146
+ return nil unless values
147
+
148
+ l, a, b = values
149
+ Color.from_oklab(l, a, b)
150
+ end
151
+
152
+ def self.parse_oklch(params)
153
+ values = parse_oklch_values(params, 3)
154
+ return nil unless values
155
+
156
+ l, c, h = values
157
+ Color.from_oklch(l, c, h)
158
+ end
159
+
160
+ def self.parse_color_function(params)
161
+ # Parse color(space values...)
162
+ parts = params.split(/\s+/, 2)
163
+ return nil unless parts.length == 2
164
+
165
+ space = parts[0]
166
+ values_str = parts[1]
167
+
168
+ case space
169
+ when "srgb"
170
+ values = parse_color_values(values_str, 3)
171
+ return nil unless values
172
+
173
+ r, g, b = values
174
+ Color.from_rgb(r, g, b)
175
+ when "srgb-linear"
176
+ values = parse_color_values(values_str, 3)
177
+ return nil unless values
178
+
179
+ r, g, b = values
180
+ Color.from_lrgb(r, g, b)
181
+ when "display-p3"
182
+ # For now, approximate as sRGB
183
+ values = parse_color_values(values_str, 3)
184
+ return nil unless values
185
+
186
+ r, g, b = values
187
+ Color.from_rgb(r, g, b)
188
+ when "a98-rgb"
189
+ # For now, approximate as sRGB
190
+ values = parse_color_values(values_str, 3)
191
+ return nil unless values
192
+
193
+ r, g, b = values
194
+ Color.from_rgb(r, g, b)
195
+ when "prophoto-rgb"
196
+ # For now, approximate as sRGB
197
+ values = parse_color_values(values_str, 3)
198
+ return nil unless values
199
+
200
+ r, g, b = values
201
+ Color.from_rgb(r, g, b)
202
+ when "rec2020"
203
+ # For now, approximate as sRGB
204
+ values = parse_color_values(values_str, 3)
205
+ return nil unless values
206
+
207
+ r, g, b = values
208
+ Color.from_rgb(r, g, b)
209
+ end
210
+ end
211
+
212
+ # Helper methods for parsing values
213
+
214
+ def self.parse_color_values(str, expected_count)
215
+ values = str.split(/\s*,\s*/).map(&:strip)
216
+ return nil unless values.length == expected_count
217
+
218
+ values.map do |v|
219
+ parse_numeric_value(v)
220
+ end.compact
221
+ end
222
+
223
+ def self.parse_hsl_values(str, expected_count)
224
+ values = str.split(/\s*,\s*/).map(&:strip)
225
+ return nil unless values.length == expected_count
226
+
227
+ parsed = []
228
+ values.each_with_index do |v, i|
229
+ val = if i.zero? # Hue
230
+ parse_angle_value(v)
231
+
232
+ else # Saturation, Lightness, Alpha
233
+ parse_percentage_or_number(v)
234
+
235
+ end
236
+ return nil unless val
237
+
238
+ parsed << val
239
+ end
240
+ parsed
241
+ end
242
+
243
+ def self.parse_hwb_values(str)
244
+ values = str.split(/\s*,\s*/).map(&:strip)
245
+ return nil unless values.length >= 3
246
+
247
+ h = parse_angle_value(values[0])
248
+ w = parse_percentage_or_number(values[1])
249
+ b = parse_percentage_or_number(values[2])
250
+ a = values[3] ? parse_numeric_value(values[3]) : 1.0
251
+
252
+ return nil unless h && w && b && a
253
+
254
+ [h, w, b, a]
255
+ end
256
+
257
+ def self.parse_lab_values(str, expected_count)
258
+ values = str.split(/\s+/, expected_count).map(&:strip)
259
+ return nil unless values.length == expected_count
260
+
261
+ l = parse_percentage_or_number(values[0])
262
+ a = parse_numeric_value(values[1])
263
+ b = parse_numeric_value(values[2])
264
+
265
+ return nil unless l && a && b
266
+
267
+ [l, a, b]
268
+ end
269
+
270
+ def self.parse_lch_values(str, expected_count)
271
+ values = str.split(/\s+/, expected_count).map(&:strip)
272
+ return nil unless values.length == expected_count
273
+
274
+ l = parse_percentage_or_number(values[0])
275
+ c = parse_numeric_value(values[1])
276
+ h = parse_angle_value(values[2])
277
+
278
+ return nil unless l && c && h
279
+
280
+ [l, c, h]
281
+ end
282
+
283
+ def self.parse_oklab_values(str, expected_count)
284
+ values = str.split(/\s+/, expected_count).map(&:strip)
285
+ return nil unless values.length == expected_count
286
+
287
+ l = parse_percentage_or_number(values[0])
288
+ a = parse_numeric_value(values[1])
289
+ b = parse_numeric_value(values[2])
290
+
291
+ return nil unless l && a && b
292
+
293
+ [l, a, b]
294
+ end
295
+
296
+ def self.parse_oklch_values(str, expected_count)
297
+ values = str.split(/\s+/, expected_count).map(&:strip)
298
+ return nil unless values.length == expected_count
299
+
300
+ l = parse_percentage_or_number(values[0])
301
+ c = parse_numeric_value(values[1])
302
+ h = parse_angle_value(values[2])
303
+
304
+ return nil unless l && c && h
305
+
306
+ [l, c, h]
307
+ end
308
+
309
+ def self.parse_numeric_value(str)
310
+ return nil unless str
311
+
312
+ if str.end_with?("%")
313
+ (str.chomp("%").to_f / 100.0)
314
+ else
315
+ str.to_f
316
+ end
317
+ rescue StandardError
318
+ nil
319
+ end
320
+
321
+ def self.parse_percentage_or_number(str)
322
+ return nil unless str
323
+
324
+ if str.end_with?("%")
325
+ str.chomp("%").to_f / 100.0
326
+ else
327
+ str.to_f
328
+ end
329
+ rescue StandardError
330
+ nil
331
+ end
332
+
333
+ def self.parse_angle_value(str)
334
+ return nil unless str
335
+
336
+ if str.end_with?("deg")
337
+ str.chomp("deg").to_f
338
+ elsif str.end_with?("rad")
339
+ str.chomp("rad").to_f * 180.0 / Math::PI
340
+ elsif str.end_with?("grad")
341
+ str.chomp("grad").to_f * 0.9
342
+ elsif str.end_with?("turn")
343
+ str.chomp("turn").to_f * 360.0
344
+ else
345
+ str.to_f # Assume degrees
346
+ end
347
+ rescue StandardError
348
+ nil
349
+ end
350
+
351
+ # Color space conversion functions
352
+
353
+ def self.hsl_to_rgb(h, s, l)
354
+ h /= 360.0 # Normalize hue to 0-1
355
+
356
+ c = (1 - ((2 * l) - 1).abs) * s
357
+ x = c * (1 - (((h * 6) % 2) - 1).abs)
358
+ m = l - (c / 2)
359
+
360
+ if h < 1.0 / 6
361
+ r = c
362
+ g = x
363
+ b = 0
364
+ elsif h < 2.0 / 6
365
+ r = x
366
+ g = c
367
+ b = 0
368
+ elsif h < 3.0 / 6
369
+ r = 0
370
+ g = c
371
+ b = x
372
+ elsif h < 4.0 / 6
373
+ r = 0
374
+ g = x
375
+ b = c
376
+ elsif h < 5.0 / 6
377
+ r = x
378
+ g = 0
379
+ b = c
380
+ else
381
+ r = c
382
+ g = 0
383
+ b = x
384
+ end
385
+
386
+ [r + m, g + m, b + m]
387
+ end
388
+
389
+ def self.hwb_to_rgb(h, w, b)
390
+ # Normalize values
391
+ h /= 360.0
392
+
393
+ # Calculate RGB from HSL equivalent
394
+ if w + b >= 1
395
+ gray = w / (w + b)
396
+ [gray, gray, gray]
397
+ else
398
+ rgb = hsl_to_rgb(h * 360, 1, 0.5)
399
+ r, g, b_rgb = rgb
400
+
401
+ # Apply whiteness and blackness
402
+ r = (r * (1 - w - b)) + w
403
+ g = (g * (1 - w - b)) + w
404
+ b_rgb = (b_rgb * (1 - w - b)) + w
405
+
406
+ [r, g, b_rgb]
407
+ end
408
+ end
409
+
410
+ def self.lab_to_xyz(l, a, b)
411
+ # CIELAB to XYZ conversion (D65 white point)
412
+ y = (l + 16) / 116
413
+ x = (a / 500) + y
414
+ z = y - (b / 200)
415
+
416
+ x = x**3 > 0.008856 ? x**3 : (x - (16 / 116)) / 7.787
417
+ y = y**3 > 0.008856 ? y**3 : (y - (16 / 116)) / 7.787
418
+ z = z**3 > 0.008856 ? z**3 : (z - (16 / 116)) / 7.787
419
+
420
+ # D65 white point
421
+ x *= 0.95047
422
+ y *= 1.0
423
+ z *= 1.08883
424
+
425
+ [x, y, z]
426
+ end
427
+
428
+ def self.lch_to_lab(l, c, h)
429
+ h_rad = h * Math::PI / 180.0
430
+ a = c * Math.cos(h_rad)
431
+ b = c * Math.sin(h_rad)
432
+ [l, a, b]
433
+ end
434
+
435
+ def self.xyz_to_rgb(x, y, z)
436
+ # XYZ to linear RGB
437
+ r = (x * 3.2406) + (y * -1.5372) + (z * -0.4986)
438
+ g = (x * -0.9689) + (y * 1.8758) + (z * 0.0415)
439
+ b = (x * 0.0557) + (y * -0.2040) + (z * 1.0570)
440
+
441
+ # Linear RGB to sRGB
442
+ [r, g, b].map do |v|
443
+ if v > 0.0031308
444
+ (1.055 * (v**(1 / 2.4))) - 0.055
445
+ else
446
+ 12.92 * v
447
+ end
448
+ end
449
+ end
450
+ end
451
+ end
452
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Abachrome
4
+ module Parsers
5
+ class Hex
6
+ HEX_PATTERN = /^#?([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{4}|[0-9A-Fa-f]{8})$/
7
+
8
+ def self.parse(input)
9
+ hex = input.gsub(/^#/, "")
10
+ return nil unless hex.match?(HEX_PATTERN)
11
+
12
+ case hex.length
13
+ when 3
14
+ parse_short_hex(hex)
15
+ when 4
16
+ parse_short_hex_with_alpha(hex)
17
+ when 6
18
+ parse_full_hex(hex)
19
+ when 8
20
+ parse_full_hex_with_alpha(hex)
21
+ end
22
+ end
23
+
24
+ def self.parse_short_hex(hex)
25
+ r, g, b = hex.chars.map { |c| (c + c).to_i(16) }
26
+ Color.from_rgb(r / 255.0, g / 255.0, b / 255.0)
27
+ end
28
+
29
+ def self.parse_short_hex_with_alpha(hex)
30
+ r, g, b, a = hex.chars.map { |c| (c + c).to_i(16) }
31
+ Color.from_rgb(r / 255.0, g / 255.0, b / 255.0, a / 255.0)
32
+ end
33
+
34
+ def self.parse_full_hex(hex)
35
+ r = hex[0, 2].to_i(16)
36
+ g = hex[2, 2].to_i(16)
37
+ b = hex[4, 2].to_i(16)
38
+ Color.from_rgb(r / 255.0, g / 255.0, b / 255.0)
39
+ end
40
+
41
+ def self.parse_full_hex_with_alpha(hex)
42
+ r = hex[0, 2].to_i(16)
43
+ g = hex[2, 2].to_i(16)
44
+ b = hex[4, 2].to_i(16)
45
+ a = hex[6, 2].to_i(16)
46
+ Color.from_rgb(r / 255.0, g / 255.0, b / 255.0, a / 255.0)
47
+ end
48
+ end
49
+ end
50
+ end
51
+
52
+ # Copyright (c) 2025 Durable Programming, LLC. All rights reserved.
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Abachrome
4
+ module Parsers
5
+ class Tailwind
6
+ # Matches Tailwind color patterns like:
7
+ # - gray-400
8
+ # - blue-900/20 (with opacity)
9
+ # - slate-50
10
+ TAILWIND_PATTERN = %r{^([a-z]+)-(\d+)(?:/(\d+(?:\.\d+)?))?$}
11
+
12
+ def self.parse(input)
13
+ match = input.match(TAILWIND_PATTERN)
14
+ return nil unless match
15
+
16
+ color_name = match[1]
17
+ shade = match[2]
18
+ opacity = match[3]
19
+
20
+ # Look up the color in the Tailwind color palette
21
+ color_shades = Named::Tailwind::COLORS[color_name]
22
+ return nil unless color_shades
23
+
24
+ rgb_values = color_shades[shade]
25
+ return nil unless rgb_values
26
+
27
+ # Convert RGB values to 0-1 range
28
+ r, g, b = rgb_values.map { |v| v / 255.0 }
29
+
30
+ # Calculate alpha from opacity percentage if provided
31
+ alpha = if opacity
32
+ opacity_value = opacity.to_f
33
+ # Opacity in Tailwind is a percentage (0-100)
34
+ opacity_value / 100.0
35
+ else
36
+ 1.0
37
+ end
38
+
39
+ Color.from_rgb(r, g, b, alpha)
40
+ end
41
+ end
42
+ end
43
+ end
44
+
45
+ # Copyright (c) 2025 Durable Programming, LLC. All rights reserved.