cataract 0.1.0

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 (90) hide show
  1. checksums.yaml +7 -0
  2. data/.clang-tidy +30 -0
  3. data/.github/workflows/ci-macos.yml +12 -0
  4. data/.github/workflows/ci.yml +77 -0
  5. data/.github/workflows/test.yml +76 -0
  6. data/.gitignore +45 -0
  7. data/.overcommit.yml +38 -0
  8. data/.rubocop.yml +83 -0
  9. data/BENCHMARKS.md +201 -0
  10. data/CHANGELOG.md +1 -0
  11. data/Gemfile +27 -0
  12. data/LICENSE +21 -0
  13. data/RAGEL_MIGRATION.md +60 -0
  14. data/README.md +292 -0
  15. data/Rakefile +209 -0
  16. data/benchmarks/benchmark_harness.rb +193 -0
  17. data/benchmarks/benchmark_merging.rb +121 -0
  18. data/benchmarks/benchmark_optimization_comparison.rb +168 -0
  19. data/benchmarks/benchmark_parsing.rb +153 -0
  20. data/benchmarks/benchmark_ragel_removal.rb +56 -0
  21. data/benchmarks/benchmark_runner.rb +70 -0
  22. data/benchmarks/benchmark_serialization.rb +180 -0
  23. data/benchmarks/benchmark_shorthand.rb +109 -0
  24. data/benchmarks/benchmark_shorthand_expansion.rb +176 -0
  25. data/benchmarks/benchmark_specificity.rb +124 -0
  26. data/benchmarks/benchmark_string_allocation.rb +151 -0
  27. data/benchmarks/benchmark_stylesheet_to_s.rb +62 -0
  28. data/benchmarks/benchmark_to_s_cached.rb +55 -0
  29. data/benchmarks/benchmark_value_splitter.rb +54 -0
  30. data/benchmarks/benchmark_yjit.rb +158 -0
  31. data/benchmarks/benchmark_yjit_workers.rb +61 -0
  32. data/benchmarks/profile_to_s.rb +23 -0
  33. data/benchmarks/speedup_calculator.rb +83 -0
  34. data/benchmarks/system_metadata.rb +81 -0
  35. data/benchmarks/templates/benchmarks.md.erb +221 -0
  36. data/benchmarks/yjit_tests.rb +141 -0
  37. data/cataract.gemspec +34 -0
  38. data/cliff.toml +92 -0
  39. data/examples/color_conversion_visual_test/color_conversion_test.html +3603 -0
  40. data/examples/color_conversion_visual_test/generate.rb +202 -0
  41. data/examples/color_conversion_visual_test/template.html.erb +259 -0
  42. data/examples/css_analyzer/analyzer.rb +164 -0
  43. data/examples/css_analyzer/analyzers/base.rb +33 -0
  44. data/examples/css_analyzer/analyzers/colors.rb +133 -0
  45. data/examples/css_analyzer/analyzers/important.rb +88 -0
  46. data/examples/css_analyzer/analyzers/properties.rb +61 -0
  47. data/examples/css_analyzer/analyzers/specificity.rb +68 -0
  48. data/examples/css_analyzer/templates/report.html.erb +575 -0
  49. data/examples/css_analyzer.rb +69 -0
  50. data/examples/github_analysis.html +5343 -0
  51. data/ext/cataract/cataract.c +1086 -0
  52. data/ext/cataract/cataract.h +174 -0
  53. data/ext/cataract/css_parser.c +1435 -0
  54. data/ext/cataract/extconf.rb +48 -0
  55. data/ext/cataract/import_scanner.c +174 -0
  56. data/ext/cataract/merge.c +973 -0
  57. data/ext/cataract/shorthand_expander.c +902 -0
  58. data/ext/cataract/specificity.c +213 -0
  59. data/ext/cataract/value_splitter.c +116 -0
  60. data/ext/cataract_color/cataract_color.c +16 -0
  61. data/ext/cataract_color/color_conversion.c +1687 -0
  62. data/ext/cataract_color/color_conversion.h +136 -0
  63. data/ext/cataract_color/color_conversion_lab.c +571 -0
  64. data/ext/cataract_color/color_conversion_named.c +259 -0
  65. data/ext/cataract_color/color_conversion_oklab.c +547 -0
  66. data/ext/cataract_color/extconf.rb +23 -0
  67. data/ext/cataract_old/cataract.c +393 -0
  68. data/ext/cataract_old/cataract.h +250 -0
  69. data/ext/cataract_old/css_parser.c +933 -0
  70. data/ext/cataract_old/extconf.rb +67 -0
  71. data/ext/cataract_old/import_scanner.c +174 -0
  72. data/ext/cataract_old/merge.c +776 -0
  73. data/ext/cataract_old/shorthand_expander.c +902 -0
  74. data/ext/cataract_old/specificity.c +213 -0
  75. data/ext/cataract_old/stylesheet.c +290 -0
  76. data/ext/cataract_old/value_splitter.c +116 -0
  77. data/lib/cataract/at_rule.rb +97 -0
  78. data/lib/cataract/color_conversion.rb +18 -0
  79. data/lib/cataract/declarations.rb +332 -0
  80. data/lib/cataract/import_resolver.rb +210 -0
  81. data/lib/cataract/rule.rb +131 -0
  82. data/lib/cataract/stylesheet.rb +716 -0
  83. data/lib/cataract/stylesheet_scope.rb +257 -0
  84. data/lib/cataract/version.rb +5 -0
  85. data/lib/cataract.rb +107 -0
  86. data/lib/tasks/gem.rake +158 -0
  87. data/scripts/fuzzer/run.rb +828 -0
  88. data/scripts/fuzzer/worker.rb +99 -0
  89. data/scripts/generate_benchmarks_md.rb +155 -0
  90. metadata +135 -0
@@ -0,0 +1,571 @@
1
+ // color_conversion_lab.c - CIE L*a*b* and LCH color space conversions
2
+ //
3
+ // Implementation of CIE 1976 (L*, a*, b*) and LCH color space conversions.
4
+ //
5
+ // ABOUT LAB:
6
+ // CIE L*a*b* (CIELAB) is a color space designed to be perceptually uniform,
7
+ // meaning that a change of the same amount in a color value should produce
8
+ // a change of about the same visual importance. Key properties:
9
+ // - Device-independent (unlike RGB which depends on display characteristics)
10
+ // - L* represents lightness (0 = black, 100 = white)
11
+ // - a* represents green-red axis (negative = green, positive = red)
12
+ // - b* represents blue-yellow axis (negative = blue, positive = yellow)
13
+ // - Covers entire range of human color perception
14
+ //
15
+ // ABOUT LCH:
16
+ // CIE LCH (cylindrical Lab) uses polar coordinates for the same color space:
17
+ // - L (lightness) is identical to Lab
18
+ // - C (chroma) = sqrt(a² + b²), represents "amount of color"
19
+ // - H (hue) = atan2(b, a), hue angle in degrees
20
+ // LCH is often more intuitive than Lab for color manipulation.
21
+ //
22
+ // COLOR CONVERSION PIPELINE:
23
+ // Parse: lab(L a b) → XYZ → linear RGB → sRGB (0-255) → struct color_ir
24
+ // lch(L C H) → Lab → XYZ → linear RGB → sRGB (0-255) → struct color_ir
25
+ // Format: struct color_ir → sRGB (0-255) → linear RGB → XYZ → lab(L a b)
26
+ // struct color_ir → sRGB (0-255) → linear RGB → XYZ → Lab → lch(L C H)
27
+ //
28
+ // REFERENCES:
29
+ // - CSS Color Module Level 4: https://www.w3.org/TR/css-color-4/#lab-colors
30
+ // - CIE 1976 L*a*b*: https://en.wikipedia.org/wiki/CIELAB_color_space
31
+ // - Bruce Lindbloom: http://www.brucelindbloom.com/
32
+
33
+ #include "color_conversion.h"
34
+ #include <math.h>
35
+ #include <stdlib.h>
36
+ #include <ctype.h>
37
+
38
+ // Forward declarations for internal helpers
39
+ static void srgb_to_linear_rgb(int r, int g, int b, double *lr, double *lg, double *lb);
40
+ static void linear_rgb_to_srgb(double lr, double lg, double lb, int *r, int *g, int *b);
41
+ static void linear_rgb_to_xyz_d65(double lr, double lg, double lb, double *x, double *y, double *z);
42
+ static void xyz_d65_to_linear_rgb(double x, double y, double z, double *lr, double *lg, double *lb);
43
+ static void xyz_d50_to_d65(double x50, double y50, double z50, double *x65, double *y65, double *z65);
44
+ static void xyz_d65_to_d50(double x65, double y65, double z65, double *x50, double *y50, double *z50);
45
+ static void xyz_to_lab(double x, double y, double z, double *L, double *a, double *b);
46
+ static void lab_to_xyz(double L, double a, double b, double *x, double *y, double *z);
47
+ static void lab_to_lch(double L, double a, double b, double *out_L, double *out_C, double *out_H);
48
+ static void lch_to_lab(double L, double C, double H, double *out_L, double *out_a, double *out_b);
49
+ static double parse_float(const char **p, double percent_max);
50
+
51
+ // =============================================================================
52
+ // GAMMA CORRECTION: sRGB ↔ Linear RGB
53
+ // =============================================================================
54
+
55
+ // sRGB gamma correction constants (IEC 61966-2-1:1999)
56
+ #define SRGB_GAMMA_THRESHOLD_INV 0.04045 // Inverse transform threshold
57
+ #define SRGB_GAMMA_THRESHOLD_FWD 0.0031308 // Forward transform threshold
58
+ #define SRGB_GAMMA_LINEAR_SLOPE 12.92 // Linear segment slope
59
+ #define SRGB_GAMMA_OFFSET 0.055 // Gamma function offset
60
+ #define SRGB_GAMMA_SCALE 1.055 // Gamma function scale (1 + offset)
61
+ #define SRGB_GAMMA_EXPONENT 2.4 // Gamma exponent
62
+
63
+ // Convert sRGB (0-255) to linear RGB (0.0-1.0)
64
+ // Applies inverse gamma: removes the sRGB nonlinearity
65
+ static void srgb_to_linear_rgb(int r, int g, int b, double *lr, double *lg, double *lb) {
66
+ double rs = r / 255.0;
67
+ double gs = g / 255.0;
68
+ double bs = b / 255.0;
69
+
70
+ // sRGB inverse transfer function (IEC 61966-2-1:1999)
71
+ *lr = (rs <= SRGB_GAMMA_THRESHOLD_INV) ? rs / SRGB_GAMMA_LINEAR_SLOPE
72
+ : pow((rs + SRGB_GAMMA_OFFSET) / SRGB_GAMMA_SCALE, SRGB_GAMMA_EXPONENT);
73
+ *lg = (gs <= SRGB_GAMMA_THRESHOLD_INV) ? gs / SRGB_GAMMA_LINEAR_SLOPE
74
+ : pow((gs + SRGB_GAMMA_OFFSET) / SRGB_GAMMA_SCALE, SRGB_GAMMA_EXPONENT);
75
+ *lb = (bs <= SRGB_GAMMA_THRESHOLD_INV) ? bs / SRGB_GAMMA_LINEAR_SLOPE
76
+ : pow((bs + SRGB_GAMMA_OFFSET) / SRGB_GAMMA_SCALE, SRGB_GAMMA_EXPONENT);
77
+ }
78
+
79
+ // Convert linear RGB (0.0-1.0) to sRGB (0-255)
80
+ // Applies gamma: adds the sRGB nonlinearity
81
+ static void linear_rgb_to_srgb(double lr, double lg, double lb, int *r, int *g, int *b) {
82
+ // Clamp to valid range [0.0, 1.0]
83
+ if (lr < 0.0) lr = 0.0;
84
+ if (lr > 1.0) lr = 1.0;
85
+ if (lg < 0.0) lg = 0.0;
86
+ if (lg > 1.0) lg = 1.0;
87
+ if (lb < 0.0) lb = 0.0;
88
+ if (lb > 1.0) lb = 1.0;
89
+
90
+ // sRGB forward transfer function (IEC 61966-2-1:1999)
91
+ double rs = (lr <= SRGB_GAMMA_THRESHOLD_FWD) ? lr * SRGB_GAMMA_LINEAR_SLOPE
92
+ : SRGB_GAMMA_SCALE * pow(lr, 1.0/SRGB_GAMMA_EXPONENT) - SRGB_GAMMA_OFFSET;
93
+ double gs = (lg <= SRGB_GAMMA_THRESHOLD_FWD) ? lg * SRGB_GAMMA_LINEAR_SLOPE
94
+ : SRGB_GAMMA_SCALE * pow(lg, 1.0/SRGB_GAMMA_EXPONENT) - SRGB_GAMMA_OFFSET;
95
+ double bs = (lb <= SRGB_GAMMA_THRESHOLD_FWD) ? lb * SRGB_GAMMA_LINEAR_SLOPE
96
+ : SRGB_GAMMA_SCALE * pow(lb, 1.0/SRGB_GAMMA_EXPONENT) - SRGB_GAMMA_OFFSET;
97
+
98
+ // Convert to 0-255 range and round
99
+ *r = (int)(rs * 255.0 + 0.5);
100
+ *g = (int)(gs * 255.0 + 0.5);
101
+ *b = (int)(bs * 255.0 + 0.5);
102
+
103
+ // Clamp to valid byte range
104
+ if (*r < 0) *r = 0;
105
+ if (*r > 255) *r = 255;
106
+ if (*g < 0) *g = 0;
107
+ if (*g > 255) *g = 255;
108
+ if (*b < 0) *b = 0;
109
+ if (*b > 255) *b = 255;
110
+ }
111
+
112
+ // =============================================================================
113
+ // LINEAR RGB ↔ XYZ CONVERSIONS
114
+ // =============================================================================
115
+
116
+ // Convert linear RGB to CIE XYZ (D65 illuminant, sRGB primaries)
117
+ // Matrix from http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html
118
+ static void linear_rgb_to_xyz_d65(double lr, double lg, double lb, double *x, double *y, double *z) {
119
+ // sRGB to XYZ-D65 transformation matrix (M = sRGB primaries × D65 white point)
120
+ *x = lr * 0.4124564 + lg * 0.3575761 + lb * 0.1804375; // X from R,G,B
121
+ *y = lr * 0.2126729 + lg * 0.7151522 + lb * 0.0721750; // Y from R,G,B (luminance)
122
+ *z = lr * 0.0193339 + lg * 0.1191920 + lb * 0.9503041; // Z from R,G,B
123
+ }
124
+
125
+ // Convert CIE XYZ to linear RGB (D65 illuminant, sRGB primaries)
126
+ // Matrix from http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html
127
+ static void xyz_d65_to_linear_rgb(double x, double y, double z, double *lr, double *lg, double *lb) {
128
+ // XYZ-D65 to sRGB transformation matrix (M^-1)
129
+ *lr = x * 3.2404542 + y * -1.5371385 + z * -0.4985314; // R from X,Y,Z
130
+ *lg = x * -0.9692660 + y * 1.8760108 + z * 0.0415560; // G from X,Y,Z
131
+ *lb = x * 0.0556434 + y * -0.2040259 + z * 1.0572252; // B from X,Y,Z
132
+ }
133
+
134
+ // =============================================================================
135
+ // CHROMATIC ADAPTATION: D50 ↔ D65
136
+ // =============================================================================
137
+ // Lab uses D50, sRGB uses D65, so we need chromatic adaptation
138
+ // Bradford matrices from CSS Color Module Level 4 spec
139
+
140
+ // Convert XYZ D50 to XYZ D65
141
+ static void xyz_d50_to_d65(double x50, double y50, double z50, double *x65, double *y65, double *z65) {
142
+ // Bradford chromatic adaptation matrix D50→D65
143
+ *x65 = x50 * 0.9554734527042182 // M[0][0]
144
+ + y50 * -0.023098536874261423 // M[0][1]
145
+ + z50 * 0.0632593086610217; // M[0][2]
146
+ *y65 = x50 * -0.028369706963208136 // M[1][0]
147
+ + y50 * 1.0099954580058226 // M[1][1]
148
+ + z50 * 0.021041398966943008; // M[1][2]
149
+ *z65 = x50 * 0.012314001688319899 // M[2][0]
150
+ + y50 * -0.020507696433477912 // M[2][1]
151
+ + z50 * 1.3303659366080753; // M[2][2]
152
+ }
153
+
154
+ // Convert XYZ D65 to XYZ D50
155
+ static void xyz_d65_to_d50(double x65, double y65, double z65, double *x50, double *y50, double *z50) {
156
+ // Bradford chromatic adaptation matrix D65→D50 (inverse of above)
157
+ *x50 = x65 * 1.0479298208405488 // M^-1[0][0]
158
+ + y65 * 0.022946793341019088 // M^-1[0][1]
159
+ + z65 * -0.05019222954313557; // M^-1[0][2]
160
+ *y50 = x65 * 0.029627815688159344 // M^-1[1][0]
161
+ + y65 * 0.990434484573249 // M^-1[1][1]
162
+ + z65 * -0.01707382502938514; // M^-1[1][2]
163
+ *z50 = x65 * -0.009243058152591178 // M^-1[2][0]
164
+ + y65 * 0.015055144896577895 // M^-1[2][1]
165
+ + z65 * 0.7518742899580008; // M^-1[2][2]
166
+ }
167
+
168
+ // =============================================================================
169
+ // XYZ ↔ LAB CONVERSIONS
170
+ // =============================================================================
171
+
172
+ // CIE Standard Illuminant D50 white point (used for Lab in CSS)
173
+ // From CSS Color Module Level 4 spec
174
+ #define XYZ_WHITE_X 0.96422
175
+ #define XYZ_WHITE_Y 1.00000
176
+ #define XYZ_WHITE_Z 0.82521
177
+
178
+ // CIE Lab constants from CSS Color Module Level 4
179
+ #define LAB_EPSILON (216.0 / 24389.0) // 6^3 / 29^3
180
+ #define LAB_KAPPA (24389.0 / 27.0) // 29^3 / 3^3
181
+
182
+ // LCH powerless hue threshold (per W3C CSS Color Module Level 4)
183
+ // When chroma is below this value, hue is considered "powerless" (missing)
184
+ #define LCH_CHROMA_EPSILON 0.0015
185
+
186
+ // Convert XYZ to CIE L*a*b* (CSS Color Module Level 4 algorithm)
187
+ static void xyz_to_lab(double x, double y, double z, double *L, double *a, double *b) {
188
+ // Normalize by D65 white point
189
+ double xn = x / XYZ_WHITE_X;
190
+ double yn = y / XYZ_WHITE_Y;
191
+ double zn = z / XYZ_WHITE_Z;
192
+
193
+ // Apply f function to each component
194
+ double fx = (xn > LAB_EPSILON) ? pow(xn, 1.0/3.0) : (LAB_KAPPA * xn + 16.0) / 116.0;
195
+ double fy = (yn > LAB_EPSILON) ? pow(yn, 1.0/3.0) : (LAB_KAPPA * yn + 16.0) / 116.0;
196
+ double fz = (zn > LAB_EPSILON) ? pow(zn, 1.0/3.0) : (LAB_KAPPA * zn + 16.0) / 116.0;
197
+
198
+ *L = 116.0 * fy - 16.0;
199
+ *a = 500.0 * (fx - fy);
200
+ *b = 200.0 * (fy - fz);
201
+ }
202
+
203
+ // Convert CIE L*a*b* to XYZ (CSS Color Module Level 4 algorithm)
204
+ static void lab_to_xyz(double L, double a, double b, double *x, double *y, double *z) {
205
+ double fy = (L + 16.0) / 116.0;
206
+ double fx = a / 500.0 + fy;
207
+ double fz = fy - b / 200.0;
208
+
209
+ // Apply inverse f function
210
+ double xn = (pow(fx, 3) > LAB_EPSILON) ? pow(fx, 3) : (116.0 * fx - 16.0) / LAB_KAPPA;
211
+ double yn = (L > LAB_KAPPA * LAB_EPSILON) ? pow((L + 16.0) / 116.0, 3) : L / LAB_KAPPA;
212
+ double zn = (pow(fz, 3) > LAB_EPSILON) ? pow(fz, 3) : (116.0 * fz - 16.0) / LAB_KAPPA;
213
+
214
+ *x = xn * XYZ_WHITE_X;
215
+ *y = yn * XYZ_WHITE_Y;
216
+ *z = zn * XYZ_WHITE_Z;
217
+ }
218
+
219
+ // =============================================================================
220
+ // LAB ↔ LCH COORDINATE CONVERSION (Cartesian ↔ Polar)
221
+ // =============================================================================
222
+ //
223
+ // LCH is the cylindrical/polar representation of Lab, similar to how
224
+ // HSL relates to RGB. The conversion is straightforward:
225
+ //
226
+ // Lab → LCH (Cartesian to Polar):
227
+ // L (lightness) stays the same
228
+ // C (chroma) = sqrt(a² + b²)
229
+ // H (hue) = atan2(b, a) converted to degrees
230
+ //
231
+ // LCH → Lab (Polar to Cartesian):
232
+ // L (lightness) stays the same
233
+ // a = C * cos(H)
234
+ // b = C * sin(H)
235
+ //
236
+ // W3C Spec: https://www.w3.org/TR/css-color-4/#lch-to-lab
237
+ // - L: 0% = 0.0, 100% = 100.0 (same as Lab)
238
+ // - C: 0% = 0, 100% = 150 (chroma), negative values clamped to 0
239
+ // - H: hue angle in degrees (0-360)
240
+ // - 0° = purplish red (positive a axis)
241
+ // - 90° = mustard yellow (positive b axis)
242
+ // - 180° = greenish cyan (negative a axis)
243
+ // - 270° = sky blue (negative b axis)
244
+ // - Powerless hue: when C <= 0.0015, hue is powerless
245
+
246
+ // Convert Lab (L, a, b) to LCH (L, C, H)
247
+ static void lab_to_lch(double L, double a, double b, double *out_L, double *out_C, double *out_H) {
248
+ *out_L = L;
249
+ *out_C = sqrt(a * a + b * b);
250
+
251
+ // Calculate hue angle in degrees
252
+ // atan2 returns radians in range [-π, π]
253
+ double h_rad = atan2(b, a);
254
+ *out_H = h_rad * 180.0 / M_PI; // Convert to degrees
255
+
256
+ // Normalize to [0, 360) range
257
+ if (*out_H < 0.0) {
258
+ *out_H += 360.0;
259
+ }
260
+
261
+ // Per W3C spec: if chroma is very small (near zero), hue is powerless
262
+ // and should be treated as missing/0
263
+ if (*out_C <= LCH_CHROMA_EPSILON) {
264
+ *out_H = 0.0; // Powerless hue
265
+ }
266
+ }
267
+
268
+ // Convert LCH (L, C, H) to Lab (L, a, b)
269
+ static void lch_to_lab(double L, double C, double H, double *out_L, double *out_a, double *out_b) {
270
+ *out_L = L;
271
+
272
+ // Clamp negative chroma to 0 (per W3C spec)
273
+ if (C < 0.0) {
274
+ C = 0.0;
275
+ }
276
+
277
+ // Convert hue angle from degrees to radians
278
+ double h_rad = H * M_PI / 180.0;
279
+
280
+ // Convert polar to Cartesian
281
+ *out_a = C * cos(h_rad);
282
+ *out_b = C * sin(h_rad);
283
+ }
284
+
285
+ // =============================================================================
286
+ // PARSING HELPERS
287
+ // =============================================================================
288
+
289
+ // Parse a floating point number with optional percentage
290
+ // Returns the value, or raises error on invalid syntax
291
+ // If percentage is found, value is scaled by percent_max
292
+ // e.g., "50%" with percent_max=100.0 returns 50.0
293
+ static double parse_float(const char **p, double percent_max) {
294
+ char *end;
295
+ double value = strtod(*p, &end);
296
+
297
+ if (end == *p) {
298
+ rb_raise(rb_eArgError, "Expected number in color value");
299
+ }
300
+
301
+ *p = end;
302
+ SKIP_WHITESPACE(*p);
303
+
304
+ // Check for percentage
305
+ if (**p == '%') {
306
+ value = (value / 100.0) * percent_max;
307
+ (*p)++;
308
+ SKIP_WHITESPACE(*p);
309
+ }
310
+
311
+ return value;
312
+ }
313
+
314
+ // =============================================================================
315
+ // PUBLIC API: Parse and Format Lab
316
+ // =============================================================================
317
+
318
+ // Parse lab() CSS function to intermediate representation
319
+ // Format: lab(L a b) or lab(L a b / alpha)
320
+ // L: 0-100 or 0%-100% (lightness)
321
+ // a, b: typically -125 to 125 (but unbounded)
322
+ // alpha: 0-1 or 0%-100%
323
+ struct color_ir parse_lab(VALUE lab_value) {
324
+ struct color_ir color;
325
+ INIT_COLOR_IR(color);
326
+
327
+ const char *str = StringValueCStr(lab_value);
328
+ const char *p = str;
329
+
330
+ // Skip "lab("
331
+ if (strncmp(p, "lab(", 4) != 0) {
332
+ rb_raise(rb_eArgError, "Invalid lab() syntax: must start with 'lab('");
333
+ }
334
+ p += 4;
335
+ SKIP_WHITESPACE(p);
336
+
337
+ // Parse L (lightness): 0-100 or 0%-100%
338
+ double L = parse_float(&p, 100.0);
339
+
340
+ // Clamp L to [0, 100] per spec
341
+ if (L < 0.0) L = 0.0;
342
+ if (L > 100.0) L = 100.0;
343
+
344
+ SKIP_SEPARATOR(p);
345
+
346
+ // Parse a (green-red axis)
347
+ // a is typically -125 to 125, but can be percentage relative to -125/125
348
+ double a = parse_float(&p, 125.0);
349
+
350
+ SKIP_SEPARATOR(p);
351
+
352
+ // Parse b (blue-yellow axis)
353
+ // b is typically -125 to 125, but can be percentage relative to -125/125
354
+ double b = parse_float(&p, 125.0);
355
+
356
+ SKIP_WHITESPACE(p);
357
+
358
+ // Check for optional alpha
359
+ if (*p == '/') {
360
+ p++;
361
+ SKIP_WHITESPACE(p);
362
+ color.alpha = parse_float(&p, 1.0);
363
+
364
+ // Clamp alpha to [0, 1]
365
+ if (color.alpha < 0.0) color.alpha = 0.0;
366
+ if (color.alpha > 1.0) color.alpha = 1.0;
367
+
368
+ SKIP_WHITESPACE(p);
369
+ }
370
+
371
+ // Expect closing paren
372
+ if (*p != ')') {
373
+ rb_raise(rb_eArgError, "Invalid lab() syntax: expected closing ')'");
374
+ }
375
+
376
+ // Convert Lab → XYZ D50 → XYZ D65 → linear RGB → sRGB
377
+ double x_d50, y_d50, z_d50;
378
+ lab_to_xyz(L, a, b, &x_d50, &y_d50, &z_d50);
379
+
380
+ // Chromatic adaptation D50 → D65
381
+ double x_d65, y_d65, z_d65;
382
+ xyz_d50_to_d65(x_d50, y_d50, z_d50, &x_d65, &y_d65, &z_d65);
383
+
384
+ double lr, lg, lb;
385
+ xyz_d65_to_linear_rgb(x_d65, y_d65, z_d65, &lr, &lg, &lb);
386
+
387
+ linear_rgb_to_srgb(lr, lg, lb, &color.red, &color.green, &color.blue);
388
+
389
+ // Store linear RGB for high precision
390
+ color.has_linear_rgb = 1;
391
+ color.linear_r = lr;
392
+ color.linear_g = lg;
393
+ color.linear_b = lb;
394
+
395
+ RB_GC_GUARD(lab_value);
396
+ return color;
397
+ }
398
+
399
+ // Format intermediate representation to lab() CSS function
400
+ // Returns Ruby string like "lab(L a b)" or "lab(L a b / alpha)"
401
+ VALUE format_lab(struct color_ir color, int use_modern_syntax) {
402
+ (void)use_modern_syntax; // Lab only has one syntax
403
+
404
+ double lr, lg, lb;
405
+
406
+ // Use high-precision linear RGB if available, otherwise convert from sRGB
407
+ if (color.has_linear_rgb) {
408
+ lr = color.linear_r;
409
+ lg = color.linear_g;
410
+ lb = color.linear_b;
411
+ } else {
412
+ srgb_to_linear_rgb(color.red, color.green, color.blue, &lr, &lg, &lb);
413
+ }
414
+
415
+ // Convert linear RGB → XYZ D65 → XYZ D50 → Lab
416
+ double x_d65, y_d65, z_d65;
417
+ linear_rgb_to_xyz_d65(lr, lg, lb, &x_d65, &y_d65, &z_d65);
418
+
419
+ // Chromatic adaptation D65 → D50
420
+ double x_d50, y_d50, z_d50;
421
+ xyz_d65_to_d50(x_d65, y_d65, z_d65, &x_d50, &y_d50, &z_d50);
422
+
423
+ double L, a, b;
424
+ xyz_to_lab(x_d50, y_d50, z_d50, &L, &a, &b);
425
+
426
+ char buf[128];
427
+ if (color.alpha >= 0.0) {
428
+ FORMAT_LAB_ALPHA(buf, L, a, b, color.alpha);
429
+ } else {
430
+ FORMAT_LAB(buf, L, a, b);
431
+ }
432
+
433
+ return rb_str_new_cstr(buf);
434
+ }
435
+
436
+ // =============================================================================
437
+ // PUBLIC API: Parse and Format LCH
438
+ // =============================================================================
439
+
440
+ // Parse lch() CSS function to intermediate representation
441
+ // Format: lch(L C H) or lch(L C H / alpha)
442
+ // L: 0-100 or 0%-100% (lightness)
443
+ // C: 0-150 or 0%-100% (chroma, where 100% = 150)
444
+ // H: hue angle in degrees (0-360, wraps)
445
+ // alpha: 0-1 or 0%-100%
446
+ struct color_ir parse_lch(VALUE lch_value) {
447
+ struct color_ir color;
448
+ INIT_COLOR_IR(color);
449
+
450
+ const char *str = StringValueCStr(lch_value);
451
+ const char *p = str;
452
+
453
+ // Skip "lch("
454
+ if (strncmp(p, "lch(", 4) != 0) {
455
+ rb_raise(rb_eArgError, "Invalid lch() syntax: must start with 'lch('");
456
+ }
457
+ p += 4;
458
+ SKIP_WHITESPACE(p);
459
+
460
+ // Parse L (lightness): 0-100 or 0%-100%
461
+ double L = parse_float(&p, 100.0);
462
+
463
+ // Clamp L to [0, 100] per spec
464
+ if (L < 0.0) L = 0.0;
465
+ if (L > 100.0) L = 100.0;
466
+
467
+ SKIP_SEPARATOR(p);
468
+
469
+ // Parse C (chroma): 0-150 or 0%-100% (where 100% = 150)
470
+ double C = parse_float(&p, 150.0);
471
+
472
+ // Clamp negative chroma to 0 per spec
473
+ if (C < 0.0) C = 0.0;
474
+
475
+ SKIP_SEPARATOR(p);
476
+
477
+ // Parse H (hue): degrees, can be any value (wraps around)
478
+ double H = parse_float(&p, 1.0); // Hue is not a percentage typically
479
+
480
+ // Normalize hue to [0, 360) range
481
+ H = fmod(H, 360.0);
482
+ if (H < 0.0) {
483
+ H += 360.0;
484
+ }
485
+
486
+ SKIP_WHITESPACE(p);
487
+
488
+ // Check for optional alpha
489
+ if (*p == '/') {
490
+ p++;
491
+ SKIP_WHITESPACE(p);
492
+ color.alpha = parse_float(&p, 1.0);
493
+
494
+ // Clamp alpha to [0, 1]
495
+ if (color.alpha < 0.0) color.alpha = 0.0;
496
+ if (color.alpha > 1.0) color.alpha = 1.0;
497
+
498
+ SKIP_WHITESPACE(p);
499
+ }
500
+
501
+ // Expect closing paren
502
+ if (*p != ')') {
503
+ rb_raise(rb_eArgError, "Invalid lch() syntax: expected closing ')'");
504
+ }
505
+
506
+ // Convert LCH → Lab → XYZ D50 → XYZ D65 → linear RGB → sRGB
507
+ double lab_L, lab_a, lab_b;
508
+ lch_to_lab(L, C, H, &lab_L, &lab_a, &lab_b);
509
+
510
+ double x_d50, y_d50, z_d50;
511
+ lab_to_xyz(lab_L, lab_a, lab_b, &x_d50, &y_d50, &z_d50);
512
+
513
+ // Chromatic adaptation D50 → D65
514
+ double x_d65, y_d65, z_d65;
515
+ xyz_d50_to_d65(x_d50, y_d50, z_d50, &x_d65, &y_d65, &z_d65);
516
+
517
+ double lr, lg, lb;
518
+ xyz_d65_to_linear_rgb(x_d65, y_d65, z_d65, &lr, &lg, &lb);
519
+
520
+ linear_rgb_to_srgb(lr, lg, lb, &color.red, &color.green, &color.blue);
521
+
522
+ // Store linear RGB for high precision
523
+ color.has_linear_rgb = 1;
524
+ color.linear_r = lr;
525
+ color.linear_g = lg;
526
+ color.linear_b = lb;
527
+
528
+ RB_GC_GUARD(lch_value);
529
+ return color;
530
+ }
531
+
532
+ // Format intermediate representation to lch() CSS function
533
+ // Returns Ruby string like "lch(L C H)" or "lch(L C H / alpha)"
534
+ VALUE format_lch(struct color_ir color, int use_modern_syntax) {
535
+ (void)use_modern_syntax; // LCH only has one syntax
536
+
537
+ double lr, lg, lb;
538
+
539
+ // Use high-precision linear RGB if available, otherwise convert from sRGB
540
+ if (color.has_linear_rgb) {
541
+ lr = color.linear_r;
542
+ lg = color.linear_g;
543
+ lb = color.linear_b;
544
+ } else {
545
+ srgb_to_linear_rgb(color.red, color.green, color.blue, &lr, &lg, &lb);
546
+ }
547
+
548
+ // Convert linear RGB → XYZ D65 → XYZ D50 → Lab → LCH
549
+ double x_d65, y_d65, z_d65;
550
+ linear_rgb_to_xyz_d65(lr, lg, lb, &x_d65, &y_d65, &z_d65);
551
+
552
+ // Chromatic adaptation D65 → D50
553
+ double x_d50, y_d50, z_d50;
554
+ xyz_d65_to_d50(x_d65, y_d65, z_d65, &x_d50, &y_d50, &z_d50);
555
+
556
+ double lab_L, lab_a, lab_b;
557
+ xyz_to_lab(x_d50, y_d50, z_d50, &lab_L, &lab_a, &lab_b);
558
+
559
+ // Convert Lab to LCH
560
+ double L, C, H;
561
+ lab_to_lch(lab_L, lab_a, lab_b, &L, &C, &H);
562
+
563
+ char buf[128];
564
+ if (color.alpha >= 0.0) {
565
+ FORMAT_LCH_ALPHA(buf, L, C, H, color.alpha);
566
+ } else {
567
+ FORMAT_LCH(buf, L, C, H);
568
+ }
569
+
570
+ return rb_str_new_cstr(buf);
571
+ }