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,1687 @@
1
+ // color_conversion.c - CSS color format conversion
2
+ //
3
+ // HOW TO ADD A NEW COLOR FORMAT
4
+ // ===============================
5
+ //
6
+ // This module uses an intermediate representation (IR) to convert between color formats.
7
+ // All conversions go through: Source Format → IR → Target Format
8
+ //
9
+ // STEP 1: Define the Intermediate Representation (IR)
10
+ // ---------------------------------------------------
11
+ // The IR is defined in color_conversion.h as `struct color_ir`:
12
+ // - red, green, blue (int 0-255): sRGB values (always populated)
13
+ // - alpha (double 0.0-1.0 or -1.0): alpha channel (-1.0 = no alpha)
14
+ // - has_linear_rgb (int): flag indicating if linear RGB is available
15
+ // - linear_r, linear_g, linear_b (double 0.0-1.0): high-precision linear RGB
16
+ //
17
+ // Use INIT_COLOR_IR(color) to initialize the struct with default values.
18
+ //
19
+ // High-precision formats (oklab, oklch) should set has_linear_rgb = 1 and populate
20
+ // the linear_* fields to preserve precision and avoid quantization loss.
21
+ //
22
+ // STEP 2: Implement Parser Function
23
+ // ----------------------------------
24
+ // Signature: static struct color_ir parse_FORMAT(VALUE format_value)
25
+ //
26
+ // Example:
27
+ // static struct color_ir parse_hsl(VALUE hsl_value) {
28
+ // struct color_ir color;
29
+ // INIT_COLOR_IR(color);
30
+ //
31
+ // // Parse the CSS string (e.g., "hsl(120, 100%, 50%)")
32
+ // const char *str = StringValueCStr(hsl_value);
33
+ // // ... parse logic ...
34
+ //
35
+ // // Convert to sRGB (0-255) and store in color.red, color.green, color.blue
36
+ // // Optionally store linear RGB for high precision
37
+ //
38
+ // RB_GC_GUARD(hsl_value); // Protect from GC
39
+ // return color;
40
+ // }
41
+ //
42
+ // Tips:
43
+ // - Use macros from color_conversion.h: SKIP_WHITESPACE, PARSE_INT, etc.
44
+ // - Validate input and raise rb_eArgError for invalid syntax
45
+ // - Always add RB_GC_GUARD before returning
46
+ //
47
+ // STEP 3: Implement Formatter Function
48
+ // -------------------------------------
49
+ // Signature: static VALUE format_FORMAT(struct color_ir color, int use_modern_syntax)
50
+ //
51
+ // Example:
52
+ // static VALUE format_hsl(struct color_ir color, int use_modern_syntax) {
53
+ // // Convert from sRGB (or linear RGB if available) to target format
54
+ // // Use macros from color_conversion.h: FORMAT_HSL, FORMAT_HSLA
55
+ // char buf[64];
56
+ // if (color.alpha >= 0.0) {
57
+ // FORMAT_HSLA(buf, h, s, l, color.alpha);
58
+ // } else {
59
+ // FORMAT_HSL(buf, h, s, l);
60
+ // }
61
+ // return rb_str_new_cstr(buf);
62
+ // }
63
+ //
64
+ // Tips:
65
+ // - Check color.has_linear_rgb to use high-precision values when available
66
+ // - Define FORMAT_* macros in color_conversion.h for consistent output
67
+ //
68
+ // STEP 4: Add Detection Macros (color_conversion.h)
69
+ // --------------------------------------------------
70
+ // Add a STARTS_WITH_FORMAT macro to detect the format in CSS strings:
71
+ //
72
+ // #define STARTS_WITH_HSL(p, remaining)
73
+ // ((remaining) >= 4 && (p)[0] == 'h' && (p)[1] == 's' && (p)[2] == 'l' &&
74
+ // ((p)[3] == '(' || (p)[3] == 'a'))
75
+ //
76
+ // STEP 5: Register in Dispatchers
77
+ // --------------------------------
78
+ // Add to get_parser() function:
79
+ // ID format_id = rb_intern("format");
80
+ // if (format_id == format_id) {
81
+ // return parse_format;
82
+ // }
83
+ //
84
+ // Add to get_formatter() function:
85
+ // ID format_id = rb_intern("format");
86
+ // if (format_id == format_id) {
87
+ // return format_format;
88
+ // }
89
+ //
90
+ // STEP 6: Integrate into Converter
91
+ // ---------------------------------
92
+ // Add to convert_value_with_colors() function:
93
+ // if (STARTS_WITH_FORMAT(p, remaining) && (parser == NULL || parser == parse_format)) {
94
+ // const char *end;
95
+ // FIND_CLOSING_PAREN(p, end);
96
+ // color_len = end - p;
97
+ // VALUE format_str = rb_str_new(p, color_len);
98
+ // struct color_ir color = parse_format(format_str);
99
+ // VALUE converted = formatter(color, use_modern_syntax);
100
+ // rb_str_buf_append(result, converted);
101
+ // pos += color_len;
102
+ // found_color = 1;
103
+ // continue;
104
+ // }
105
+ //
106
+ // Add to detect_color_format() function:
107
+ // if (STARTS_WITH_FORMAT(p, remaining)) {
108
+ // return parse_format;
109
+ // }
110
+ //
111
+ // Add to matches_color_format() function:
112
+ // ID format_id = rb_intern("format");
113
+ // if (format_id == format_id) {
114
+ // return STARTS_WITH_FORMAT(p, remaining);
115
+ // }
116
+ //
117
+ // STEP 7: Add Tests
118
+ // -----------------
119
+ // Create test/test_color_conversion_FORMAT.rb with comprehensive test coverage:
120
+ // - Parsing valid values
121
+ // - Conversions to/from other formats
122
+ // - Edge cases (invalid input, boundary values)
123
+ // - Alpha channel support
124
+ // - Round-trip conversions
125
+ //
126
+ // EXAMPLE: See oklab/oklch implementations for reference
127
+ //
128
+ // ============================================================================
129
+
130
+ #include "cataract.h"
131
+ #include "color_conversion.h"
132
+ #include <ctype.h>
133
+ #include <stdio.h>
134
+ #include <math.h>
135
+
136
+ // Forward declarations
137
+ static int is_hex_digit(char c);
138
+ static int hex_char_to_int(char c);
139
+ static VALUE expand_property_if_needed(VALUE property_name, VALUE value);
140
+ VALUE rb_stylesheet_convert_colors(int argc, VALUE *argv, VALUE self);
141
+
142
+ // Parser function signature: format → IR
143
+ typedef struct color_ir (*color_parser_fn)(VALUE color_value);
144
+
145
+ // Formatter function signature: IR → format
146
+ typedef VALUE (*color_formatter_fn)(struct color_ir color, int use_modern_syntax);
147
+
148
+ // Parser functions
149
+ static struct color_ir parse_hex(VALUE hex_value);
150
+ static struct color_ir parse_rgb(VALUE rgb_value);
151
+ static struct color_ir parse_rgb_percent(VALUE rgb_value);
152
+ static struct color_ir parse_hsl(VALUE hsl_value);
153
+ static struct color_ir parse_hwb(VALUE hwb_value);
154
+
155
+ // Formatter functions
156
+ static VALUE format_hex(struct color_ir color, int use_modern_syntax);
157
+ static VALUE format_rgb(struct color_ir color, int use_modern_syntax);
158
+ static VALUE format_hsl(struct color_ir color, int use_modern_syntax);
159
+ static VALUE format_hwb(struct color_ir color, int use_modern_syntax);
160
+
161
+ // Oklab functions (defined in color_conversion_oklab.c)
162
+ extern struct color_ir parse_oklab(VALUE oklab_value);
163
+ extern VALUE format_oklab(struct color_ir color, int use_modern_syntax);
164
+
165
+ // OKLCh functions (defined in color_conversion_oklab.c)
166
+ extern struct color_ir parse_oklch(VALUE oklch_value);
167
+ extern VALUE format_oklch(struct color_ir color, int use_modern_syntax);
168
+
169
+ // Named color functions (defined in color_conversion_named.c)
170
+ extern struct color_ir parse_named(VALUE named_value);
171
+
172
+ // Lab functions (defined in color_conversion_lab.c)
173
+ extern struct color_ir parse_lab(VALUE lab_value);
174
+ extern VALUE format_lab(struct color_ir color, int use_modern_syntax);
175
+ extern struct color_ir parse_lch(VALUE lch_value);
176
+ extern VALUE format_lch(struct color_ir color, int use_modern_syntax);
177
+
178
+ // Dispatchers
179
+ static color_parser_fn get_parser(VALUE format);
180
+ static color_formatter_fn get_formatter(VALUE format);
181
+
182
+ // Exception class
183
+ static VALUE rb_eColorConversionError = Qnil;
184
+
185
+ // Initialize color conversion module
186
+ void Init_color_conversion(VALUE mCataract) {
187
+ // Define ColorConversionError exception class
188
+ rb_eColorConversionError = rb_define_class_under(mCataract, "ColorConversionError", rb_eStandardError);
189
+
190
+ // Get the Stylesheet class (bootstrapped in C, defined fully in Ruby)
191
+ VALUE cStylesheet = rb_const_get(mCataract, rb_intern("Stylesheet"));
192
+
193
+ // Add convert_colors! instance method to Stylesheet
194
+ rb_define_method(cStylesheet, "convert_colors!", rb_stylesheet_convert_colors, -1);
195
+ }
196
+
197
+ // Check if a character is a valid hex digit (0-9, a-f, A-F)
198
+ static int is_hex_digit(char c) {
199
+ return (c >= '0' && c <= '9') ||
200
+ (c >= 'a' && c <= 'f') ||
201
+ (c >= 'A' && c <= 'F');
202
+ }
203
+
204
+ // Convert hex character to integer value
205
+ static int hex_char_to_int(char c) {
206
+ if (c >= '0' && c <= '9') return c - '0';
207
+ if (c >= 'a' && c <= 'f') return c - 'a' + 10;
208
+ if (c >= 'A' && c <= 'F') return c - 'A' + 10;
209
+ return -1;
210
+ }
211
+
212
+ // Macro to check if a string contains unparseable content and preserve it if found
213
+ // Sets has_unparseable to 1 if content should be preserved, 0 otherwise
214
+ #define HAS_UNPARSEABLE_CONTENT(str, len, has_unparseable) do { \
215
+ (has_unparseable) = 0; \
216
+ if ((str) != NULL && (len) >= 4) { \
217
+ static const char *unparseable[] = { \
218
+ "calc(", "min(", "max(", "clamp(", "var(", \
219
+ "none", "infinity", "-infinity", "NaN", \
220
+ "from ", \
221
+ NULL \
222
+ }; \
223
+ for (int _i = 0; unparseable[_i] != NULL; _i++) { \
224
+ const char *_kw = unparseable[_i]; \
225
+ size_t _kw_len = strlen(_kw); \
226
+ for (long _j = 0; _j <= (len) - (long)_kw_len; _j++) { \
227
+ if (strncmp((str) + _j, _kw, _kw_len) == 0) { \
228
+ (has_unparseable) = 1; \
229
+ break; \
230
+ } \
231
+ } \
232
+ if ((has_unparseable)) break; \
233
+ } \
234
+ } \
235
+ } while(0)
236
+
237
+ // Parse hex color string to intermediate representation
238
+ // hex_value: Ruby string like "#fff", "#ffffff", or "#ff000080"
239
+ // Returns: color_ir struct with RGB values (0-255) and optional alpha (0.0-1.0)
240
+ static struct color_ir parse_hex(VALUE hex_value) {
241
+ Check_Type(hex_value, T_STRING);
242
+
243
+ const char *hex_str = RSTRING_PTR(hex_value);
244
+ long hex_len = RSTRING_LEN(hex_value);
245
+
246
+ // Must start with '#'
247
+ if (hex_len < 2 || hex_str[0] != '#') {
248
+ rb_raise(rb_eColorConversionError, "Invalid hex color: must start with '#', got '%s'", hex_str);
249
+ }
250
+
251
+ // Skip the '#' character
252
+ hex_str++;
253
+ hex_len--;
254
+
255
+ // Validate length (3, 6, or 8 digits)
256
+ if (hex_len != 3 && hex_len != 6 && hex_len != 8) {
257
+ // hex_str points past '#', so show original with '#'
258
+ rb_raise(rb_eColorConversionError,
259
+ "Invalid hex color: expected 3, 6, or 8 digits, got %ld in '%s'",
260
+ hex_len, RSTRING_PTR(hex_value));
261
+ }
262
+
263
+ // Validate all characters are hex digits
264
+ for (long i = 0; i < hex_len; i++) {
265
+ if (!is_hex_digit(hex_str[i])) {
266
+ rb_raise(rb_eColorConversionError,
267
+ "Invalid hex color: contains non-hex character '%c'",
268
+ hex_str[i]);
269
+ }
270
+ }
271
+
272
+ struct color_ir color;
273
+ INIT_COLOR_IR(color);
274
+
275
+ if (hex_len == 3) {
276
+ // 3-digit hex: #RGB -> each digit is duplicated
277
+ color.red = hex_char_to_int(hex_str[0]) * 17;
278
+ color.green = hex_char_to_int(hex_str[1]) * 17;
279
+ color.blue = hex_char_to_int(hex_str[2]) * 17;
280
+ } else {
281
+ // 6 or 8-digit hex: #RRGGBB or #RRGGBBAA
282
+ color.red = (hex_char_to_int(hex_str[0]) << 4) | hex_char_to_int(hex_str[1]);
283
+ color.green = (hex_char_to_int(hex_str[2]) << 4) | hex_char_to_int(hex_str[3]);
284
+ color.blue = (hex_char_to_int(hex_str[4]) << 4) | hex_char_to_int(hex_str[5]);
285
+
286
+ if (hex_len == 8) {
287
+ int alpha_int = (hex_char_to_int(hex_str[6]) << 4) | hex_char_to_int(hex_str[7]);
288
+ color.alpha = alpha_int / 255.0;
289
+ }
290
+ }
291
+
292
+ return color;
293
+ }
294
+
295
+ // Format intermediate representation to RGB string
296
+ // color: color_ir struct with RGB (0-255) and optional alpha (0.0-1.0)
297
+ // use_modern_syntax: 1 for "rgb(255 0 0)", 0 for "rgb(255, 0, 0)"
298
+ // Returns: Ruby string with RGB/RGBA value
299
+ static VALUE format_rgb(struct color_ir color, int use_modern_syntax) {
300
+ char rgb_buf[128];
301
+
302
+ // Use high-precision linear RGB if available (preserves precision from oklab/oklch)
303
+ if (color.has_linear_rgb) {
304
+ // Convert linear RGB to sRGB percentages
305
+ double lr = color.linear_r, lg = color.linear_g, lb = color.linear_b;
306
+
307
+ // Clamp to [0.0, 1.0]
308
+ if (lr < 0.0) lr = 0.0;
309
+ if (lr > 1.0) lr = 1.0;
310
+ if (lg < 0.0) lg = 0.0;
311
+ if (lg > 1.0) lg = 1.0;
312
+ if (lb < 0.0) lb = 0.0;
313
+ if (lb > 1.0) lb = 1.0;
314
+
315
+ // Apply sRGB gamma correction
316
+ double rs = (lr <= 0.0031308) ? lr * 12.92 : 1.055 * pow(lr, 1.0/2.4) - 0.055;
317
+ double gs = (lg <= 0.0031308) ? lg * 12.92 : 1.055 * pow(lg, 1.0/2.4) - 0.055;
318
+ double bs = (lb <= 0.0031308) ? lb * 12.92 : 1.055 * pow(lb, 1.0/2.4) - 0.055;
319
+
320
+ // Convert to percentages
321
+ double r_pct = rs * 100.0;
322
+ double g_pct = gs * 100.0;
323
+ double b_pct = bs * 100.0;
324
+
325
+ if (color.alpha >= 0.0) {
326
+ FORMAT_RGB_PERCENT_ALPHA(rgb_buf, r_pct, g_pct, b_pct, color.alpha);
327
+ } else {
328
+ FORMAT_RGB_PERCENT(rgb_buf, r_pct, g_pct, b_pct);
329
+ }
330
+ } else {
331
+ // Use integer sRGB values (0-255)
332
+ if (color.alpha >= 0.0) {
333
+ // Has alpha channel
334
+ if (use_modern_syntax) {
335
+ FORMAT_RGBA_MODERN(rgb_buf, color.red, color.green, color.blue, color.alpha);
336
+ } else {
337
+ FORMAT_RGBA_LEGACY(rgb_buf, color.red, color.green, color.blue, color.alpha);
338
+ }
339
+ } else {
340
+ // No alpha channel
341
+ if (use_modern_syntax) {
342
+ FORMAT_RGB_MODERN(rgb_buf, color.red, color.green, color.blue);
343
+ } else {
344
+ FORMAT_RGB_LEGACY(rgb_buf, color.red, color.green, color.blue);
345
+ }
346
+ }
347
+ }
348
+
349
+ return rb_str_new_cstr(rgb_buf);
350
+ }
351
+
352
+ // Parse RGB color string to intermediate representation
353
+ // rgb_value: Ruby string like "rgb(255, 0, 0)" or "rgb(255 0 0 / 0.5)"
354
+ // Returns: color_ir struct with RGB values (0-255) and optional alpha (0.0-1.0)
355
+ static struct color_ir parse_rgb(VALUE rgb_value) {
356
+ Check_Type(rgb_value, T_STRING);
357
+
358
+ const char *rgb_str = RSTRING_PTR(rgb_value);
359
+ long rgb_len = RSTRING_LEN(rgb_value);
360
+
361
+ if (rgb_len < 10 || (strncmp(rgb_str, "rgb(", 4) != 0 && strncmp(rgb_str, "rgba(", 5) != 0)) {
362
+ rb_raise(rb_eColorConversionError, "Invalid RGB color: must start with 'rgb(' or 'rgba(', got '%s'", rgb_str);
363
+ }
364
+
365
+ // Skip "rgb(" or "rgba("
366
+ const char *p = rgb_str; // "rgb(255, 128, 64, 0.5)"
367
+ if (*p == 'r' && *(p+1) == 'g' && *(p+2) == 'b') { // "rgb"
368
+ p += 3; // "(255, 128, 64, 0.5)"
369
+ if (*p == 'a') p++; // skip 'a' if rgba
370
+ if (*p == '(') p++; // "255, 128, 64, 0.5)"
371
+ }
372
+
373
+ struct color_ir color;
374
+ INIT_COLOR_IR(color);
375
+
376
+ // Check if this is percentage format by looking for '%' before first separator
377
+ const char *check = p;
378
+ int is_percent = 0;
379
+ while (*check && *check != ')' && *check != ',' && *check != '/') {
380
+ if (*check == '%') {
381
+ is_percent = 1;
382
+ break;
383
+ }
384
+ check++;
385
+ }
386
+
387
+ if (is_percent) {
388
+ // Delegate to percentage parser
389
+ return parse_rgb_percent(rgb_value);
390
+ }
391
+
392
+ SKIP_WHITESPACE(p); // "255, 128, 64, 0.5)"
393
+ PARSE_INT(p, color.red); // red=255, p=", 128, 64, 0.5)"
394
+
395
+ SKIP_SEPARATOR(p); // "128, 64, 0.5)"
396
+ PARSE_INT(p, color.green); // green=128, p=", 64, 0.5)"
397
+
398
+ SKIP_SEPARATOR(p); // "64, 0.5)"
399
+ PARSE_INT(p, color.blue); // blue=64, p=", 0.5)"
400
+
401
+ // Check for alpha (either ",alpha" or "/ alpha")
402
+ SKIP_SEPARATOR(p); // "0.5)" or ", 0.5)" or ", / 0.5)"
403
+ if (*p == '/') { // '/'
404
+ p++; // " 0.5)"
405
+ SKIP_WHITESPACE(p); // "0.5)"
406
+ }
407
+
408
+ if (*p >= '0' && *p <= '9') { // '0'
409
+ color.alpha = 0.0;
410
+ while (*p >= '0' && *p <= '9') { // '0' (integer part)
411
+ color.alpha = color.alpha * 10.0 + (*p - '0'); // alpha=0.0
412
+ p++; // ".5)"
413
+ }
414
+ if (*p == '.') { // '.'
415
+ p++; // "5)"
416
+ double decimal = 0.1;
417
+ while (*p >= '0' && *p <= '9') { // '5'
418
+ color.alpha += (*p - '0') * decimal; // alpha=0.5
419
+ decimal /= 10.0;
420
+ p++; // ")"
421
+ }
422
+ }
423
+ }
424
+
425
+ // Validate ranges
426
+ if (color.red < 0 || color.red > 255 || color.green < 0 || color.green > 255 || color.blue < 0 || color.blue > 255) {
427
+ rb_raise(rb_eColorConversionError,
428
+ "Invalid RGB values: must be 0-255, got red=%d green=%d blue=%d", color.red, color.green, color.blue);
429
+ }
430
+
431
+ if (color.alpha >= 0.0 && (color.alpha < 0.0 || color.alpha > 1.0)) {
432
+ rb_raise(rb_eColorConversionError,
433
+ "Invalid alpha value: must be 0.0-1.0, got %.10g", color.alpha);
434
+ }
435
+
436
+ return color;
437
+ }
438
+
439
+ // Parse RGB percentage color string to intermediate representation
440
+ // rgb_value: Ruby string like "rgb(70.492% 2.351% 37.073%)"
441
+ // Returns: color_ir struct with high-precision linear RGB
442
+ static struct color_ir parse_rgb_percent(VALUE rgb_value) {
443
+ Check_Type(rgb_value, T_STRING);
444
+
445
+ const char *rgb_str = RSTRING_PTR(rgb_value);
446
+ long rgb_len = RSTRING_LEN(rgb_value);
447
+
448
+ if (rgb_len < 10 || (strncmp(rgb_str, "rgb(", 4) != 0 && strncmp(rgb_str, "rgba(", 5) != 0)) {
449
+ rb_raise(rb_eColorConversionError, "Invalid RGB color: must start with 'rgb(' or 'rgba(', got '%s'", rgb_str);
450
+ }
451
+
452
+ // Skip "rgb(" or "rgba("
453
+ const char *p = rgb_str;
454
+ if (*p == 'r' && *(p+1) == 'g' && *(p+2) == 'b') {
455
+ p += 3;
456
+ if (*p == 'a') p++;
457
+ if (*p == '(') p++;
458
+ }
459
+
460
+ struct color_ir color;
461
+ INIT_COLOR_IR(color);
462
+
463
+ SKIP_WHITESPACE(p);
464
+
465
+ // Parse R%
466
+ double r_pct = 0.0;
467
+ while (*p >= '0' && *p <= '9') {
468
+ r_pct = r_pct * 10.0 + (*p - '0');
469
+ p++;
470
+ }
471
+ if (*p == '.') {
472
+ p++;
473
+ double frac = 0.1;
474
+ while (*p >= '0' && *p <= '9') {
475
+ r_pct += (*p - '0') * frac;
476
+ frac *= 0.1;
477
+ p++;
478
+ }
479
+ }
480
+ if (*p == '%') p++;
481
+
482
+ SKIP_SEPARATOR(p);
483
+
484
+ // Parse G%
485
+ double g_pct = 0.0;
486
+ while (*p >= '0' && *p <= '9') {
487
+ g_pct = g_pct * 10.0 + (*p - '0');
488
+ p++;
489
+ }
490
+ if (*p == '.') {
491
+ p++;
492
+ double frac = 0.1;
493
+ while (*p >= '0' && *p <= '9') {
494
+ g_pct += (*p - '0') * frac;
495
+ frac *= 0.1;
496
+ p++;
497
+ }
498
+ }
499
+ if (*p == '%') p++;
500
+
501
+ SKIP_SEPARATOR(p);
502
+
503
+ // Parse B%
504
+ double b_pct = 0.0;
505
+ while (*p >= '0' && *p <= '9') {
506
+ b_pct = b_pct * 10.0 + (*p - '0');
507
+ p++;
508
+ }
509
+ if (*p == '.') {
510
+ p++;
511
+ double frac = 0.1;
512
+ while (*p >= '0' && *p <= '9') {
513
+ b_pct += (*p - '0') * frac;
514
+ frac *= 0.1;
515
+ p++;
516
+ }
517
+ }
518
+ if (*p == '%') p++;
519
+
520
+ // Check for alpha
521
+ SKIP_SEPARATOR(p);
522
+ if (*p == '/') {
523
+ p++;
524
+ SKIP_WHITESPACE(p);
525
+ }
526
+
527
+ if (*p >= '0' && *p <= '9') {
528
+ color.alpha = 0.0;
529
+ while (*p >= '0' && *p <= '9') {
530
+ color.alpha = color.alpha * 10.0 + (*p - '0');
531
+ p++;
532
+ }
533
+ if (*p == '.') {
534
+ p++;
535
+ double decimal = 0.1;
536
+ while (*p >= '0' && *p <= '9') {
537
+ color.alpha += (*p - '0') * decimal;
538
+ decimal /= 10.0;
539
+ p++;
540
+ }
541
+ }
542
+ }
543
+
544
+ // Convert percentages to sRGB (0-1.0)
545
+ double rs = r_pct / 100.0;
546
+ double gs = g_pct / 100.0;
547
+ double bs = b_pct / 100.0;
548
+
549
+ // Clamp to [0, 1]
550
+ if (rs < 0.0) rs = 0.0;
551
+ if (rs > 1.0) rs = 1.0;
552
+ if (gs < 0.0) gs = 0.0;
553
+ if (gs > 1.0) gs = 1.0;
554
+ if (bs < 0.0) bs = 0.0;
555
+ if (bs > 1.0) bs = 1.0;
556
+
557
+ // Apply inverse gamma to get linear RGB for precision
558
+ double lr = (rs <= 0.04045) ? rs / 12.92 : pow((rs + 0.055) / 1.055, 2.4);
559
+ double lg = (gs <= 0.04045) ? gs / 12.92 : pow((gs + 0.055) / 1.055, 2.4);
560
+ double lb = (bs <= 0.04045) ? bs / 12.92 : pow((bs + 0.055) / 1.055, 2.4);
561
+
562
+ // Store high-precision linear RGB
563
+ color.has_linear_rgb = 1;
564
+ color.linear_r = lr;
565
+ color.linear_g = lg;
566
+ color.linear_b = lb;
567
+
568
+ // Also store integer sRGB for compatibility
569
+ color.red = (int)(rs * 255.0 + 0.5);
570
+ color.green = (int)(gs * 255.0 + 0.5);
571
+ color.blue = (int)(bs * 255.0 + 0.5);
572
+
573
+ RB_GC_GUARD(rgb_value);
574
+ return color;
575
+ }
576
+
577
+ // Format intermediate representation to hex string
578
+ // color: color_ir struct with RGB (0-255) and optional alpha (0.0-1.0)
579
+ // use_modern_syntax: unused for hex format (hex format is always the same)
580
+ // Returns: Ruby string with hex value like "#ff0000" or "#ff000080"
581
+ static VALUE format_hex(struct color_ir color, int use_modern_syntax) {
582
+ (void)use_modern_syntax; // Unused - hex format doesn't have variants
583
+
584
+ char hex_buf[10];
585
+ if (color.alpha >= 0.0) {
586
+ int alpha_int = (int)(color.alpha * 255.0 + 0.5);
587
+ FORMAT_HEX_ALPHA(hex_buf, color.red, color.green, color.blue, alpha_int);
588
+ } else {
589
+ FORMAT_HEX(hex_buf, color.red, color.green, color.blue);
590
+ }
591
+
592
+ return rb_str_new_cstr(hex_buf);
593
+ }
594
+
595
+ // Parse HSL color string to intermediate representation
596
+ // hsl_value: Ruby string like "hsl(0, 100%, 50%)" or "hsl(0, 100%, 50%, 0.5)"
597
+ // Returns: color_ir struct with RGB values (0-255) and optional alpha (0.0-1.0)
598
+ static struct color_ir parse_hsl(VALUE hsl_value) {
599
+ Check_Type(hsl_value, T_STRING);
600
+
601
+ const char *hsl_str = RSTRING_PTR(hsl_value);
602
+ long hsl_len = RSTRING_LEN(hsl_value);
603
+
604
+ if (hsl_len < 10 || (strncmp(hsl_str, "hsl(", 4) != 0 && strncmp(hsl_str, "hsla(", 5) != 0)) {
605
+ rb_raise(rb_eColorConversionError, "Invalid HSL color: must start with 'hsl(' or 'hsla(', got '%s'", hsl_str);
606
+ }
607
+
608
+ // Skip "hsl(" or "hsla("
609
+ const char *p = hsl_str; // "hsl(120, 100%, 50%, 0.75)"
610
+ if (*p == 'h' && *(p+1) == 's' && *(p+2) == 'l') { // "hsl"
611
+ p += 3; // "(120, 100%, 50%, 0.75)"
612
+ if (*p == 'a') p++; // skip 'a' if hsla
613
+ if (*p == '(') p++; // "120, 100%, 50%, 0.75)"
614
+ }
615
+
616
+ int hue, sat_int, light_int;
617
+ double saturation, lightness;
618
+ double alpha = -1.0;
619
+
620
+ // Parse hue (0-360)
621
+ SKIP_WHITESPACE(p); // "120, 100%, 50%, 0.75)"
622
+ PARSE_INT(p, hue); // hue=120, p=", 100%, 50%, 0.75)"
623
+
624
+ // Parse saturation (0-100%)
625
+ SKIP_SEPARATOR(p); // "100%, 50%, 0.75)"
626
+ PARSE_INT(p, sat_int); // sat_int=100, p="%, 50%, 0.75)"
627
+ saturation = sat_int;
628
+ if (*p == '%') p++; // " 50%, 0.75)"
629
+
630
+ // Parse lightness (0-100%)
631
+ SKIP_SEPARATOR(p); // "50%, 0.75)"
632
+ PARSE_INT(p, light_int); // light_int=50, p="%, 0.75)"
633
+ lightness = light_int;
634
+ if (*p == '%') p++; // " 0.75)"
635
+
636
+ // Check for alpha
637
+ SKIP_SEPARATOR(p); // "0.75)" or ", 0.75)" or ", / 0.75)"
638
+ if (*p == '/') { // '/'
639
+ p++; // " 0.75)"
640
+ SKIP_WHITESPACE(p); // "0.75)"
641
+ }
642
+
643
+ if (*p >= '0' && *p <= '9') { // '0'
644
+ alpha = 0.0;
645
+ while (*p >= '0' && *p <= '9') { // '0' (integer part)
646
+ alpha = alpha * 10.0 + (*p - '0'); // alpha=0.0
647
+ p++; // ".75)"
648
+ }
649
+ if (*p == '.') { // '.'
650
+ p++; // "75)"
651
+ double decimal = 0.1;
652
+ while (*p >= '0' && *p <= '9') { // '7', then '5'
653
+ alpha += (*p - '0') * decimal; // alpha=0.75
654
+ decimal /= 10.0; // decimal=0.01
655
+ p++; // "5)" then ")"
656
+ }
657
+ }
658
+ }
659
+
660
+ // Convert HSL to RGB
661
+ // Normalize saturation and lightness to 0.0-1.0
662
+ saturation /= 100.0;
663
+ lightness /= 100.0;
664
+
665
+ // Normalize hue to 0-360 range
666
+ hue = hue % 360;
667
+ if (hue < 0) hue += 360;
668
+
669
+ struct color_ir color;
670
+ INIT_COLOR_IR(color);
671
+ color.alpha = alpha;
672
+
673
+ double c = (1.0 - fabs(2.0 * lightness - 1.0)) * saturation; // TODO: Document
674
+ double x = c * (1.0 - fabs(fmod(hue / 60.0, 2.0) - 1.0));
675
+ double m = lightness - c / 2.0;
676
+
677
+ double red_prime, green_prime, blue_prime;
678
+
679
+ if (hue >= 0 && hue < 60) {
680
+ red_prime = c; green_prime = x; blue_prime = 0;
681
+ } else if (hue >= 60 && hue < 120) {
682
+ red_prime = x; green_prime = c; blue_prime = 0;
683
+ } else if (hue >= 120 && hue < 180) {
684
+ red_prime = 0; green_prime = c; blue_prime = x;
685
+ } else if (hue >= 180 && hue < 240) {
686
+ red_prime = 0; green_prime = x; blue_prime = c;
687
+ } else if (hue >= 240 && hue < 300) {
688
+ red_prime = x; green_prime = 0; blue_prime = c;
689
+ } else {
690
+ red_prime = c; green_prime = 0; blue_prime = x;
691
+ }
692
+
693
+ color.red = (int)((red_prime + m) * 255.0 + 0.5);
694
+ color.green = (int)((green_prime + m) * 255.0 + 0.5);
695
+ color.blue = (int)((blue_prime + m) * 255.0 + 0.5);
696
+
697
+ return color;
698
+ }
699
+
700
+ // Format intermediate representation to HSL string
701
+ // color: color_ir struct with RGB (0-255) and optional alpha (0.0-1.0)
702
+ // use_modern_syntax: unused for HSL format (HSL format doesn't have variants like RGB)
703
+ // Returns: Ruby string with HSL value like "hsl(0, 100%, 50%)"
704
+ static VALUE format_hsl(struct color_ir color, int use_modern_syntax) {
705
+ (void)use_modern_syntax; // Unused - HSL format doesn't have variants
706
+
707
+ // Convert RGB to HSL
708
+ double red = color.red / 255.0;
709
+ double green = color.green / 255.0;
710
+ double blue = color.blue / 255.0;
711
+
712
+ double max = red > green ? (red > blue ? red : blue) : (green > blue ? green : blue);
713
+ double min = red < green ? (red < blue ? red : blue) : (green < blue ? green : blue);
714
+ double delta = max - min;
715
+
716
+ double hue = 0.0;
717
+ double saturation = 0.0;
718
+ double lightness = (max + min) / 2.0;
719
+
720
+ if (delta > 0.0001) { // Not grayscale
721
+ saturation = lightness > 0.5 ? delta / (2.0 - max - min) : delta / (max + min);
722
+
723
+ if (max == red) {
724
+ hue = 60.0 * fmod((green - blue) / delta, 6.0);
725
+ } else if (max == green) {
726
+ hue = 60.0 * ((blue - red) / delta + 2.0);
727
+ } else {
728
+ hue = 60.0 * ((red - green) / delta + 4.0);
729
+ }
730
+
731
+ if (hue < 0) hue += 360.0;
732
+ }
733
+
734
+ int hue_int = (int)(hue + 0.5);
735
+ int sat_int = (int)(saturation * 100.0 + 0.5);
736
+ int light_int = (int)(lightness * 100.0 + 0.5);
737
+
738
+ char hsl_buf[64];
739
+ if (color.alpha >= 0.0) {
740
+ FORMAT_HSLA(hsl_buf, hue_int, sat_int, light_int, color.alpha);
741
+ } else {
742
+ FORMAT_HSL(hsl_buf, hue_int, sat_int, light_int);
743
+ }
744
+
745
+ return rb_str_new_cstr(hsl_buf);
746
+ }
747
+
748
+ // Parse HWB color string to intermediate representation
749
+ // hwb_value: Ruby string like "hwb(0 0% 0%)" or "hwb(120 30% 20% / 0.5)"
750
+ // Returns: color_ir struct with RGB (0-255) and optional alpha (0.0-1.0)
751
+ static struct color_ir parse_hwb(VALUE hwb_value) {
752
+ Check_Type(hwb_value, T_STRING);
753
+
754
+ const char *hwb_str = RSTRING_PTR(hwb_value);
755
+ long hwb_len = RSTRING_LEN(hwb_value);
756
+
757
+ if (hwb_len < 10 || (strncmp(hwb_str, "hwb(", 4) != 0 && strncmp(hwb_str, "hwba(", 5) != 0)) {
758
+ rb_raise(rb_eColorConversionError, "Invalid HWB color: must start with 'hwb(' or 'hwba(', got '%s'", hwb_str);
759
+ }
760
+
761
+ // Skip "hwb(" or "hwba("
762
+ const char *p = hwb_str; // "hwb(120 30% 20% / 0.5)"
763
+ if (*p == 'h' && *(p+1) == 'w' && *(p+2) == 'b') { // "hwb"
764
+ p += 3; // "(120 30% 20% / 0.5)"
765
+ if (*p == 'a') p++; // skip 'a' if hwba
766
+ if (*p == '(') p++; // "120 30% 20% / 0.5)"
767
+ }
768
+
769
+ int hue, white_int, black_int;
770
+ double whiteness, blackness;
771
+ double alpha = -1.0;
772
+
773
+ // Parse hue (0-360)
774
+ SKIP_WHITESPACE(p); // "120 30% 20% / 0.5)"
775
+ PARSE_INT(p, hue); // hue=120, p=" 30% 20% / 0.5)"
776
+
777
+ // Parse whiteness (0-100%)
778
+ SKIP_SEPARATOR(p); // "30% 20% / 0.5)"
779
+ PARSE_INT(p, white_int); // white_int=30, p="% 20% / 0.5)"
780
+ whiteness = white_int / 100.0;
781
+ if (*p == '%') p++; // " 20% / 0.5)"
782
+
783
+ // Parse blackness (0-100%)
784
+ SKIP_SEPARATOR(p); // "20% / 0.5)"
785
+ PARSE_INT(p, black_int); // black_int=20, p="% / 0.5)"
786
+ blackness = black_int / 100.0;
787
+ if (*p == '%') p++; // " / 0.5)"
788
+
789
+ // Check for alpha
790
+ SKIP_SEPARATOR(p); // "/ 0.5)" or "0.5)" or ")"
791
+ if (*p == '/') { // '/'
792
+ p++; // " 0.5)"
793
+ SKIP_WHITESPACE(p); // "0.5)"
794
+ }
795
+
796
+ if (*p >= '0' && *p <= '9') { // '0'
797
+ alpha = 0.0;
798
+ while (*p >= '0' && *p <= '9') { // '0' (integer part)
799
+ alpha = alpha * 10.0 + (*p - '0'); // alpha=0.0
800
+ p++; // ".5)"
801
+ }
802
+ if (*p == '.') { // '.'
803
+ p++; // "5)"
804
+ double decimal = 0.1;
805
+ while (*p >= '0' && *p <= '9') { // '5'
806
+ alpha += (*p - '0') * decimal; // alpha=0.5
807
+ decimal /= 10.0; // decimal=0.01
808
+ p++; // ")"
809
+ }
810
+ }
811
+ }
812
+
813
+ // Normalize W+B if > 100%
814
+ double wb_sum = whiteness + blackness;
815
+ if (wb_sum > 1.0) {
816
+ whiteness /= wb_sum;
817
+ blackness /= wb_sum;
818
+ }
819
+
820
+ // Convert HWB to RGB via HSL intermediate
821
+ // First convert hue to RGB with S=100%, L=50% (fully saturated color)
822
+ hue = hue % 360;
823
+ if (hue < 0) hue += 360;
824
+
825
+ // Use HSL→RGB conversion with saturation=1.0, lightness=0.5
826
+ double c = 1.0; // chroma at full saturation and 50% lightness
827
+ double x = c * (1.0 - fabs(fmod(hue / 60.0, 2.0) - 1.0));
828
+ double m = 0.0; // no adjustment needed for L=0.5
829
+
830
+ double red_prime, green_prime, blue_prime;
831
+
832
+ if (hue >= 0 && hue < 60) {
833
+ red_prime = c; green_prime = x; blue_prime = 0;
834
+ } else if (hue >= 60 && hue < 120) {
835
+ red_prime = x; green_prime = c; blue_prime = 0;
836
+ } else if (hue >= 120 && hue < 180) {
837
+ red_prime = 0; green_prime = c; blue_prime = x;
838
+ } else if (hue >= 180 && hue < 240) {
839
+ red_prime = 0; green_prime = x; blue_prime = c;
840
+ } else if (hue >= 240 && hue < 300) {
841
+ red_prime = x; green_prime = 0; blue_prime = c;
842
+ } else {
843
+ red_prime = c; green_prime = 0; blue_prime = x;
844
+ }
845
+
846
+ // Apply HWB transformation: rgb = rgb * (1 - W - B) + W
847
+ struct color_ir color;
848
+ INIT_COLOR_IR(color);
849
+ color.red = (int)(((red_prime + m) * (1.0 - whiteness - blackness) + whiteness) * 255.0 + 0.5);
850
+ color.green = (int)(((green_prime + m) * (1.0 - whiteness - blackness) + whiteness) * 255.0 + 0.5);
851
+ color.blue = (int)(((blue_prime + m) * (1.0 - whiteness - blackness) + whiteness) * 255.0 + 0.5);
852
+ color.alpha = alpha;
853
+
854
+ return color;
855
+ }
856
+
857
+ // Format intermediate representation to HWB string
858
+ // color: color_ir struct with RGB (0-255) and optional alpha (0.0-1.0)
859
+ // use_modern_syntax: unused for HWB format
860
+ // Returns: Ruby string with HWB value like "hwb(0 0% 0%)"
861
+ static VALUE format_hwb(struct color_ir color, int use_modern_syntax) {
862
+ (void)use_modern_syntax; // Unused - HWB format doesn't have variants
863
+
864
+ // Convert RGB to HWB
865
+ double red = color.red / 255.0;
866
+ double green = color.green / 255.0;
867
+ double blue = color.blue / 255.0;
868
+
869
+ double max = red > green ? (red > blue ? red : blue) : (green > blue ? green : blue);
870
+ double min = red < green ? (red < blue ? red : blue) : (green < blue ? green : blue);
871
+ double delta = max - min;
872
+
873
+ // Calculate hue (same as HSL)
874
+ double hue = 0.0;
875
+ if (delta > 0.0001) { // Not grayscale
876
+ if (max == red) {
877
+ hue = 60.0 * fmod((green - blue) / delta, 6.0);
878
+ } else if (max == green) {
879
+ hue = 60.0 * ((blue - red) / delta + 2.0);
880
+ } else {
881
+ hue = 60.0 * ((red - green) / delta + 4.0);
882
+ }
883
+
884
+ if (hue < 0) hue += 360.0;
885
+ }
886
+
887
+ // Whiteness = min component, Blackness = 1 - max component
888
+ double whiteness = min;
889
+ double blackness = 1.0 - max;
890
+
891
+ // Per W3C spec: if white + black >= 1, the color is achromatic (hue is undefined)
892
+ // https://www.w3.org/TR/css-color-4/#the-hwb-notation
893
+ // Use epsilon to account for floating-point rounding errors
894
+ double epsilon = 1.0 / 100000.0;
895
+ if (whiteness + blackness >= 1.0 - epsilon) {
896
+ hue = 0.0; // CSS serializes NaN as 0
897
+ }
898
+
899
+ int hue_int = (int)(hue + 0.5);
900
+ int white_int = (int)(whiteness * 100.0 + 0.5);
901
+ int black_int = (int)(blackness * 100.0 + 0.5);
902
+
903
+ char hwb_buf[64];
904
+ if (color.alpha >= 0.0) {
905
+ FORMAT_HWBA(hwb_buf, hue_int, white_int, black_int, color.alpha);
906
+ } else {
907
+ FORMAT_HWB(hwb_buf, hue_int, white_int, black_int);
908
+ }
909
+
910
+ return rb_str_new_cstr(hwb_buf);
911
+ }
912
+
913
+ // Get parser function for a given format
914
+ static color_parser_fn get_parser(VALUE format) {
915
+ ID format_id = SYM2ID(format);
916
+ ID hex_id = rb_intern("hex");
917
+ ID rgb_id = rb_intern("rgb");
918
+ ID hsl_id = rb_intern("hsl");
919
+ ID hwb_id = rb_intern("hwb");
920
+ ID oklab_id = rb_intern("oklab");
921
+ ID oklch_id = rb_intern("oklch");
922
+ ID lab_id = rb_intern("lab");
923
+ ID lch_id = rb_intern("lch");
924
+ ID named_id = rb_intern("named");
925
+
926
+ if (format_id == hex_id) {
927
+ return parse_hex;
928
+ }
929
+ if (format_id == rgb_id) {
930
+ return parse_rgb;
931
+ }
932
+ if (format_id == hsl_id) {
933
+ return parse_hsl;
934
+ }
935
+ if (format_id == hwb_id) {
936
+ return parse_hwb;
937
+ }
938
+ if (format_id == oklab_id) {
939
+ return parse_oklab;
940
+ }
941
+ if (format_id == oklch_id) {
942
+ return parse_oklch;
943
+ }
944
+ if (format_id == lab_id) {
945
+ return parse_lab;
946
+ }
947
+ if (format_id == lch_id) {
948
+ return parse_lch;
949
+ }
950
+ if (format_id == named_id) {
951
+ return parse_named;
952
+ }
953
+
954
+ return NULL;
955
+ }
956
+
957
+ // Get formatter function for a given format
958
+ static color_formatter_fn get_formatter(VALUE format) {
959
+ ID format_id = SYM2ID(format);
960
+ ID hex_id = rb_intern("hex");
961
+ ID rgb_id = rb_intern("rgb");
962
+ ID rgba_id = rb_intern("rgba");
963
+ ID hsl_id = rb_intern("hsl");
964
+ ID hsla_id = rb_intern("hsla");
965
+ ID hwb_id = rb_intern("hwb");
966
+ ID hwba_id = rb_intern("hwba");
967
+ ID oklab_id = rb_intern("oklab");
968
+ ID oklch_id = rb_intern("oklch");
969
+ ID lab_id = rb_intern("lab");
970
+ ID lch_id = rb_intern("lch");
971
+
972
+ if (format_id == hex_id) {
973
+ return format_hex;
974
+ }
975
+ if (format_id == rgb_id || format_id == rgba_id) {
976
+ return format_rgb;
977
+ }
978
+ if (format_id == hsl_id || format_id == hsla_id) {
979
+ return format_hsl;
980
+ }
981
+ if (format_id == hwb_id || format_id == hwba_id) {
982
+ return format_hwb;
983
+ }
984
+ if (format_id == oklab_id) {
985
+ return format_oklab;
986
+ }
987
+ if (format_id == oklch_id) {
988
+ return format_oklch;
989
+ }
990
+ if (format_id == lab_id) {
991
+ return format_lab;
992
+ }
993
+ if (format_id == lch_id) {
994
+ return format_lch;
995
+ }
996
+
997
+ return NULL;
998
+ }
999
+
1000
+ // Convert a value that may contain multiple colors or colors mixed with other values
1001
+ // (e.g., "border-color: #fff #000 #ccc" or "box-shadow: 0 0 10px #ff0000")
1002
+ // parser: specific parser to use (e.g., parse_hex), or NULL for auto-detect all formats
1003
+ // Returns a new Ruby string with all colors converted, or Qnil if no colors found
1004
+ static VALUE convert_value_with_colors(VALUE value, color_parser_fn parser, color_formatter_fn formatter, int use_modern_syntax) {
1005
+ if (NIL_P(value) || TYPE(value) != T_STRING) {
1006
+ return Qnil;
1007
+ }
1008
+
1009
+ const char *input = RSTRING_PTR(value);
1010
+ long input_len = RSTRING_LEN(value);
1011
+
1012
+ DEBUG_PRINTF("convert_value_with_colors: input='%.*s' parser=%p formatter=%p\n", (int)input_len, input, (void*)parser, (void*)formatter);
1013
+
1014
+ // Build output string with converted colors
1015
+ VALUE result = rb_str_buf_new(input_len * 2); // Allocate generous space
1016
+ long pos = 0;
1017
+ int found_color = 0;
1018
+ int in_url = 0; // Track if we're inside url()
1019
+ int url_paren_depth = 0; // Track paren depth inside url()
1020
+
1021
+ while (pos < input_len) {
1022
+ const char *p = input + pos;
1023
+ long remaining = input_len - pos;
1024
+ long color_len = 0;
1025
+
1026
+ // Check for url( to skip content inside URLs
1027
+ if (!in_url && remaining >= 4 && p[0] == 'u' && p[1] == 'r' && p[2] == 'l' && p[3] == '(') {
1028
+ in_url = 1;
1029
+ url_paren_depth = 1; // Start counting from the url's opening paren
1030
+ // Copy "url("
1031
+ rb_str_buf_cat(result, p, 4);
1032
+ pos += 4;
1033
+ continue;
1034
+ }
1035
+
1036
+ // If we're inside url(), track parens and copy as-is
1037
+ if (in_url) {
1038
+ if (*p == '(') {
1039
+ url_paren_depth++;
1040
+ } else if (*p == ')') {
1041
+ url_paren_depth--;
1042
+ if (url_paren_depth == 0) {
1043
+ in_url = 0; // Exiting url()
1044
+ }
1045
+ }
1046
+ rb_str_buf_cat(result, &input[pos], 1);
1047
+ pos++;
1048
+ continue;
1049
+ }
1050
+
1051
+ // Skip whitespace and preserve it
1052
+ while (pos < input_len && (input[pos] == ' ' || input[pos] == '\t')) {
1053
+ rb_str_buf_cat(result, &input[pos], 1);
1054
+ pos++;
1055
+ p = input + pos;
1056
+ remaining = input_len - pos;
1057
+ }
1058
+
1059
+ if (pos >= input_len) break;
1060
+
1061
+ // Check for hex color
1062
+ if (*p == '#' && (parser == NULL || parser == parse_hex)) {
1063
+ // Find the end of the hex color (next space, comma, or delimiter)
1064
+ const char *end = p + 1;
1065
+ while (*end && *end != ' ' && *end != ',' && *end != ';' && *end != ')' && *end != '\n') {
1066
+ end++;
1067
+ }
1068
+ color_len = end - p;
1069
+
1070
+ // Parse and convert hex color
1071
+ VALUE hex_str = rb_str_new(p, color_len);
1072
+ struct color_ir color = parse_hex(hex_str);
1073
+ VALUE converted = formatter(color, use_modern_syntax);
1074
+ rb_str_buf_append(result, converted);
1075
+ pos += color_len;
1076
+ found_color = 1;
1077
+ continue;
1078
+ }
1079
+
1080
+ // Check for rgb/rgba
1081
+ if (STARTS_WITH_RGB(p, remaining) && (parser == NULL || parser == parse_rgb)) {
1082
+ const char *end;
1083
+ FIND_CLOSING_PAREN(p, end);
1084
+ color_len = end - p;
1085
+
1086
+ // Skip if contains unparseable content (calc, none, etc.)
1087
+ int skip;
1088
+ HAS_UNPARSEABLE_CONTENT(p, color_len, skip);
1089
+ if (skip) {
1090
+ rb_str_buf_append(result, rb_str_new(p, color_len));
1091
+ pos += color_len;
1092
+ found_color = 1;
1093
+ continue;
1094
+ }
1095
+
1096
+ VALUE rgb_str = rb_str_new(p, color_len);
1097
+ struct color_ir color = parse_rgb(rgb_str);
1098
+ VALUE converted = formatter(color, use_modern_syntax);
1099
+ rb_str_buf_append(result, converted);
1100
+ pos += color_len;
1101
+ found_color = 1;
1102
+ continue;
1103
+ }
1104
+
1105
+ // Check for hsl/hsla
1106
+ if (STARTS_WITH_HSL(p, remaining) && (parser == NULL || parser == parse_hsl)) {
1107
+ const char *end;
1108
+ FIND_CLOSING_PAREN(p, end);
1109
+ color_len = end - p;
1110
+
1111
+ // Skip if contains unparseable content (calc, none, etc.)
1112
+ int skip;
1113
+ HAS_UNPARSEABLE_CONTENT(p, color_len, skip);
1114
+ if (skip) {
1115
+ rb_str_buf_append(result, rb_str_new(p, color_len));
1116
+ pos += color_len;
1117
+ found_color = 1;
1118
+ continue;
1119
+ }
1120
+
1121
+ VALUE hsl_str = rb_str_new(p, color_len);
1122
+ struct color_ir color = parse_hsl(hsl_str);
1123
+ VALUE converted = formatter(color, use_modern_syntax);
1124
+ rb_str_buf_append(result, converted);
1125
+ pos += color_len;
1126
+ found_color = 1;
1127
+ continue;
1128
+ }
1129
+
1130
+ // Check for hwb/hwba
1131
+ if (STARTS_WITH_HWB(p, remaining) && (parser == NULL || parser == parse_hwb)) {
1132
+ const char *end;
1133
+ FIND_CLOSING_PAREN(p, end);
1134
+ color_len = end - p;
1135
+
1136
+ // Skip if contains unparseable content (calc, none, etc.)
1137
+ int skip;
1138
+ HAS_UNPARSEABLE_CONTENT(p, color_len, skip);
1139
+ if (skip) {
1140
+ rb_str_buf_append(result, rb_str_new(p, color_len));
1141
+ pos += color_len;
1142
+ found_color = 1;
1143
+ continue;
1144
+ }
1145
+
1146
+ VALUE hwb_str = rb_str_new(p, color_len);
1147
+ struct color_ir color = parse_hwb(hwb_str);
1148
+ VALUE converted = formatter(color, use_modern_syntax);
1149
+ rb_str_buf_append(result, converted);
1150
+ pos += color_len;
1151
+ found_color = 1;
1152
+ continue;
1153
+ }
1154
+
1155
+ // Check for oklab
1156
+ if (STARTS_WITH_OKLAB(p, remaining) && (parser == NULL || parser == parse_oklab)) {
1157
+ const char *end;
1158
+ FIND_CLOSING_PAREN(p, end);
1159
+ color_len = end - p;
1160
+
1161
+ // Skip if contains unparseable content (calc, none, etc.)
1162
+ int skip;
1163
+ HAS_UNPARSEABLE_CONTENT(p, color_len, skip);
1164
+ if (skip) {
1165
+ rb_str_buf_append(result, rb_str_new(p, color_len));
1166
+ pos += color_len;
1167
+ found_color = 1;
1168
+ continue;
1169
+ }
1170
+
1171
+ VALUE oklab_str = rb_str_new(p, color_len);
1172
+ struct color_ir color = parse_oklab(oklab_str);
1173
+ VALUE converted = formatter(color, use_modern_syntax);
1174
+ rb_str_buf_append(result, converted);
1175
+ pos += color_len;
1176
+ found_color = 1;
1177
+ continue;
1178
+ }
1179
+
1180
+ // Check for oklch
1181
+ if (STARTS_WITH_OKLCH(p, remaining) && (parser == NULL || parser == parse_oklch)) {
1182
+ const char *end;
1183
+ FIND_CLOSING_PAREN(p, end);
1184
+ color_len = end - p;
1185
+
1186
+ // Skip if contains unparseable content (calc, none, etc.)
1187
+ int skip;
1188
+ HAS_UNPARSEABLE_CONTENT(p, color_len, skip);
1189
+ if (skip) {
1190
+ rb_str_buf_append(result, rb_str_new(p, color_len));
1191
+ pos += color_len;
1192
+ found_color = 1;
1193
+ continue;
1194
+ }
1195
+
1196
+ VALUE oklch_str = rb_str_new(p, color_len);
1197
+ struct color_ir color = parse_oklch(oklch_str);
1198
+ VALUE converted = formatter(color, use_modern_syntax);
1199
+ rb_str_buf_append(result, converted);
1200
+ pos += color_len;
1201
+ found_color = 1;
1202
+ continue;
1203
+ }
1204
+
1205
+ // Check for lch (must check before lab since both start with 'l')
1206
+ if (STARTS_WITH_LCH(p, remaining) && (parser == NULL || parser == parse_lch)) {
1207
+ const char *end;
1208
+ FIND_CLOSING_PAREN(p, end);
1209
+ color_len = end - p;
1210
+
1211
+ // Skip if contains unparseable content (calc, none, etc.)
1212
+ int skip;
1213
+ HAS_UNPARSEABLE_CONTENT(p, color_len, skip);
1214
+ if (skip) {
1215
+ rb_str_buf_append(result, rb_str_new(p, color_len));
1216
+ pos += color_len;
1217
+ found_color = 1;
1218
+ continue;
1219
+ }
1220
+
1221
+ VALUE lch_str = rb_str_new(p, color_len);
1222
+ struct color_ir color = parse_lch(lch_str);
1223
+ VALUE converted = formatter(color, use_modern_syntax);
1224
+ rb_str_buf_append(result, converted);
1225
+ pos += color_len;
1226
+ found_color = 1;
1227
+ continue;
1228
+ }
1229
+
1230
+ // Check for lab
1231
+ if (STARTS_WITH_LAB(p, remaining) && (parser == NULL || parser == parse_lab)) {
1232
+ const char *end;
1233
+ FIND_CLOSING_PAREN(p, end);
1234
+ color_len = end - p;
1235
+
1236
+ // Skip if contains unparseable content (calc, none, etc.)
1237
+ int skip;
1238
+ HAS_UNPARSEABLE_CONTENT(p, color_len, skip);
1239
+ if (skip) {
1240
+ rb_str_buf_append(result, rb_str_new(p, color_len));
1241
+ pos += color_len;
1242
+ found_color = 1;
1243
+ continue;
1244
+ }
1245
+
1246
+ VALUE lab_str = rb_str_new(p, color_len);
1247
+ struct color_ir color = parse_lab(lab_str);
1248
+ VALUE converted = formatter(color, use_modern_syntax);
1249
+ rb_str_buf_append(result, converted);
1250
+ pos += color_len;
1251
+ found_color = 1;
1252
+ continue;
1253
+ }
1254
+
1255
+ // Check for named colors (alphabetic, length 3-20)
1256
+ // Try early to catch valid color names, skip word if not found
1257
+ if (((*p >= 'a' && *p <= 'z') || (*p >= 'A' && *p <= 'Z')) && (parser == NULL || parser == parse_named)) {
1258
+ DEBUG_PRINTF("Checking alphabetic word at pos %ld, parser=%p, parse_named=%p\n", pos, (void*)parser, (void*)parse_named);
1259
+ // Find the end of the alphabetic word
1260
+ const char *end = p + 1;
1261
+ while (*end && ((*end >= 'a' && *end <= 'z') || (*end >= 'A' && *end <= 'Z'))) {
1262
+ end++;
1263
+ }
1264
+ color_len = end - p;
1265
+ DEBUG_PRINTF("Word length = %ld\n", color_len);
1266
+
1267
+ // Named colors are 3-20 characters (red to lightgoldenrodyellow)
1268
+ if (color_len >= 3 && color_len <= 20) {
1269
+ VALUE named_str = rb_str_new(p, color_len);
1270
+ DEBUG_PRINTF("Trying to parse: '%s'\n", StringValueCStr(named_str));
1271
+ struct color_ir color = parse_named(named_str);
1272
+ DEBUG_PRINTF("parse_named returned red=%d\n", color.red);
1273
+
1274
+ // Check if valid (red >= 0 means found)
1275
+ if (color.red >= 0) {
1276
+ DEBUG_PRINTF("Valid color! Converting...\n");
1277
+ VALUE converted = formatter(color, use_modern_syntax);
1278
+ rb_str_buf_append(result, converted);
1279
+ pos += color_len;
1280
+ found_color = 1;
1281
+ continue;
1282
+ }
1283
+
1284
+ DEBUG_PRINTF("Not a valid color, copying word\n");
1285
+ // Not a valid color - copy the whole word as-is and continue
1286
+ rb_str_buf_cat(result, p, color_len);
1287
+ pos += color_len;
1288
+ continue;
1289
+ }
1290
+ }
1291
+
1292
+ // Not a color - copy character as-is
1293
+ rb_str_buf_cat(result, &input[pos], 1);
1294
+ pos++;
1295
+ }
1296
+
1297
+ RB_GC_GUARD(value); // Prevent GC of value while we hold input pointer
1298
+
1299
+ DEBUG_PRINTF("Returning %s\n", found_color ? "result" : "Qnil (no colors found)");
1300
+
1301
+ return found_color ? result : Qnil;
1302
+ }
1303
+
1304
+ // Auto-detect the color format of a value string
1305
+ // Returns the appropriate parser function, or NULL if not recognized
1306
+ static color_parser_fn detect_color_format(VALUE value) {
1307
+ if (NIL_P(value) || TYPE(value) != T_STRING) {
1308
+ return NULL;
1309
+ }
1310
+
1311
+ const char *val_str = RSTRING_PTR(value);
1312
+ long val_len = RSTRING_LEN(value);
1313
+
1314
+ if (val_len == 0) {
1315
+ return NULL;
1316
+ }
1317
+
1318
+ // Skip leading whitespace
1319
+ const char *p = val_str;
1320
+ while (*p == ' ' || *p == '\t') {
1321
+ p++;
1322
+ }
1323
+
1324
+ long remaining = val_len - (p - val_str);
1325
+
1326
+ // Check for hex (starts with #)
1327
+ if (*p == '#') {
1328
+ return parse_hex;
1329
+ }
1330
+
1331
+ // Check for rgb (starts with 'rgb')
1332
+ if (STARTS_WITH_RGB(p, remaining)) {
1333
+ return parse_rgb;
1334
+ }
1335
+
1336
+ // Check for hwb (starts with 'hwb')
1337
+ if (STARTS_WITH_HWB(p, remaining)) {
1338
+ return parse_hwb;
1339
+ }
1340
+
1341
+ // Check for hsl (starts with 'hsl')
1342
+ if (STARTS_WITH_HSL(p, remaining)) {
1343
+ return parse_hsl;
1344
+ }
1345
+
1346
+ // Check for oklab (starts with 'oklab')
1347
+ if (STARTS_WITH_OKLAB(p, remaining)) {
1348
+ return parse_oklab;
1349
+ }
1350
+
1351
+ // Check for oklch (starts with 'oklch')
1352
+ if (STARTS_WITH_OKLCH(p, remaining)) {
1353
+ return parse_oklch;
1354
+ }
1355
+
1356
+ // Check for lch (starts with 'lch(') - must check before lab
1357
+ if (STARTS_WITH_LCH(p, remaining)) {
1358
+ return parse_lch;
1359
+ }
1360
+
1361
+ // Check for lab (starts with 'lab(')
1362
+ if (STARTS_WITH_LAB(p, remaining)) {
1363
+ return parse_lab;
1364
+ }
1365
+
1366
+ // Check for named colors (fallback - alphabetic characters)
1367
+ // Named colors must start with a letter
1368
+ if ((*p >= 'a' && *p <= 'z') || (*p >= 'A' && *p <= 'Z')) {
1369
+ return parse_named;
1370
+ }
1371
+
1372
+ return NULL;
1373
+ }
1374
+
1375
+ // Check if a value matches a given color format
1376
+ // Returns 1 if it matches, 0 otherwise
1377
+ static int matches_color_format(VALUE value, VALUE format) {
1378
+ if (NIL_P(value) || TYPE(value) != T_STRING) {
1379
+ return 0;
1380
+ }
1381
+
1382
+ const char *val_str = RSTRING_PTR(value);
1383
+ long val_len = RSTRING_LEN(value);
1384
+
1385
+ if (val_len == 0) {
1386
+ return 0;
1387
+ }
1388
+
1389
+ // Skip leading whitespace
1390
+ const char *p = val_str;
1391
+ while (*p == ' ' || *p == '\t') {
1392
+ p++;
1393
+ }
1394
+
1395
+ long remaining = val_len - (p - val_str);
1396
+
1397
+ ID format_id = SYM2ID(format);
1398
+ ID hex_id = rb_intern("hex");
1399
+ ID rgb_id = rb_intern("rgb");
1400
+ ID hsl_id = rb_intern("hsl");
1401
+ ID hwb_id = rb_intern("hwb");
1402
+ ID oklab_id = rb_intern("oklab");
1403
+ ID oklch_id = rb_intern("oklch");
1404
+ ID lab_id = rb_intern("lab");
1405
+ ID lch_id = rb_intern("lch");
1406
+ ID named_id = rb_intern("named");
1407
+
1408
+ if (format_id == hex_id) {
1409
+ return *p == '#';
1410
+ }
1411
+ if (format_id == rgb_id) {
1412
+ return STARTS_WITH_RGB(p, remaining);
1413
+ }
1414
+ if (format_id == hwb_id) {
1415
+ return STARTS_WITH_HWB(p, remaining);
1416
+ }
1417
+ if (format_id == hsl_id) {
1418
+ return STARTS_WITH_HSL(p, remaining);
1419
+ }
1420
+ if (format_id == oklab_id) {
1421
+ return STARTS_WITH_OKLAB(p, remaining);
1422
+ }
1423
+ if (format_id == oklch_id) {
1424
+ return STARTS_WITH_OKLCH(p, remaining);
1425
+ }
1426
+ if (format_id == lab_id) {
1427
+ return STARTS_WITH_LAB(p, remaining);
1428
+ }
1429
+ if (format_id == lch_id) {
1430
+ return STARTS_WITH_LCH(p, remaining);
1431
+ }
1432
+ if (format_id == named_id) {
1433
+ // Named colors are alphabetic (possibly with whitespace)
1434
+ // Just check that it starts with a letter
1435
+ return (*p >= 'a' && *p <= 'z') || (*p >= 'A' && *p <= 'Z');
1436
+ }
1437
+
1438
+ return 0;
1439
+ }
1440
+
1441
+ // Expand shorthand properties if needed (e.g., background → background-color, background-image, etc.)
1442
+ // Returns hash of expanded properties, or nil if no expansion needed
1443
+ static VALUE expand_property_if_needed(VALUE property_name, VALUE value) {
1444
+ Check_Type(property_name, T_STRING);
1445
+ Check_Type(value, T_STRING);
1446
+
1447
+ const char *prop = RSTRING_PTR(property_name);
1448
+
1449
+ // Check if this is a shorthand property that needs expansion
1450
+ if (strcmp(prop, "background") == 0) {
1451
+ return cataract_expand_background(Qnil, value);
1452
+ }
1453
+ // Add other shorthands if needed (margin, padding, border, font, list-style)
1454
+
1455
+ return Qnil; // No expansion needed
1456
+ }
1457
+
1458
+ // Context struct for hash iteration
1459
+ struct convert_colors_context {
1460
+ color_parser_fn parser; // NULL if auto-detect mode
1461
+ color_formatter_fn formatter;
1462
+ int use_modern_syntax;
1463
+ VALUE from_format; // :any for auto-detect
1464
+ };
1465
+
1466
+ // Context for expanding and converting shorthand properties
1467
+ struct expand_convert_context {
1468
+ color_parser_fn parser; // NULL if auto-detect mode
1469
+ color_formatter_fn formatter;
1470
+ int use_modern_syntax;
1471
+ VALUE from_format; // :any for auto-detect
1472
+ VALUE new_declarations;
1473
+ VALUE important;
1474
+ };
1475
+
1476
+ // Callback for iterating expanded properties, converting colors, and creating declaration structs
1477
+ static int convert_expanded_property_callback(VALUE prop_name, VALUE prop_value, VALUE arg) {
1478
+ struct expand_convert_context *ctx = (struct expand_convert_context *)arg;
1479
+
1480
+ if (!NIL_P(prop_value) && TYPE(prop_value) == T_STRING) {
1481
+ color_parser_fn parser;
1482
+
1483
+ // Auto-detect or use specified format
1484
+ if (ctx->parser == NULL) {
1485
+ parser = detect_color_format(prop_value);
1486
+ } else if (matches_color_format(prop_value, ctx->from_format)) {
1487
+ parser = ctx->parser;
1488
+ } else {
1489
+ parser = NULL;
1490
+ }
1491
+
1492
+ if (parser != NULL) {
1493
+ // Parse → IR → Format
1494
+ struct color_ir color = parser(prop_value);
1495
+ prop_value = ctx->formatter(color, ctx->use_modern_syntax);
1496
+ }
1497
+
1498
+ VALUE new_decl = rb_struct_new(cDeclaration, prop_name, prop_value, ctx->important, NULL);
1499
+ rb_ary_push(ctx->new_declarations, new_decl);
1500
+ }
1501
+
1502
+ return ST_CONTINUE;
1503
+ }
1504
+
1505
+
1506
+ // Ruby method: stylesheet.convert_colors!(from: :hex, to: :rgb, variant: :modern)
1507
+ // Returns self for method chaining
1508
+ VALUE rb_stylesheet_convert_colors(int argc, VALUE *argv, VALUE self) {
1509
+ VALUE kwargs;
1510
+ rb_scan_args(argc, argv, ":", &kwargs);
1511
+
1512
+ // Handle case where no kwargs provided
1513
+ if (NIL_P(kwargs)) {
1514
+ kwargs = rb_hash_new();
1515
+ }
1516
+
1517
+ // Extract keyword arguments
1518
+ VALUE from_format = rb_hash_aref(kwargs, ID2SYM(rb_intern("from")));
1519
+ VALUE to_format = rb_hash_aref(kwargs, ID2SYM(rb_intern("to")));
1520
+ VALUE variant = rb_hash_aref(kwargs, ID2SYM(rb_intern("variant")));
1521
+
1522
+ // Default from_format to :any (auto-detect)
1523
+ if (NIL_P(from_format)) {
1524
+ from_format = ID2SYM(rb_intern("any"));
1525
+ }
1526
+
1527
+ // Validate required arguments
1528
+ if (NIL_P(to_format)) {
1529
+ rb_raise(rb_eArgError, "missing keyword: :to");
1530
+ }
1531
+
1532
+ // Auto-detect variant based on to_format if not explicitly set
1533
+ // :rgba, :hsla, :hwba imply legacy syntax (rgba(), hsla(), hwba())
1534
+ // :rgb, :hsl, :hwb, :hex default to modern syntax
1535
+ if (NIL_P(variant)) {
1536
+ ID to_id = SYM2ID(to_format);
1537
+ ID rgba_id = rb_intern("rgba");
1538
+ ID hsla_id = rb_intern("hsla");
1539
+ ID hwba_id = rb_intern("hwba");
1540
+
1541
+ if (to_id == rgba_id || to_id == hsla_id || to_id == hwba_id) {
1542
+ variant = ID2SYM(rb_intern("legacy"));
1543
+ } else {
1544
+ variant = ID2SYM(rb_intern("modern"));
1545
+ }
1546
+ }
1547
+
1548
+ // Get the appropriate parser and formatter functions
1549
+ color_parser_fn parser = NULL;
1550
+ ID from_id = SYM2ID(from_format);
1551
+ ID any_id = rb_intern("any");
1552
+
1553
+ // If from is :any, we'll auto-detect per value (parser = NULL)
1554
+ if (from_id != any_id) {
1555
+ parser = get_parser(from_format);
1556
+ if (parser == NULL) {
1557
+ const char *from_str = rb_id2name(from_id);
1558
+ rb_raise(rb_eArgError, "Unsupported source format: %s", from_str);
1559
+ }
1560
+ }
1561
+
1562
+ color_formatter_fn formatter = get_formatter(to_format);
1563
+ if (formatter == NULL) {
1564
+ const char *to_str = rb_id2name(SYM2ID(to_format));
1565
+ rb_raise(rb_eArgError, "Unsupported target format: %s", to_str);
1566
+ }
1567
+
1568
+ // Determine syntax variant
1569
+ ID variant_id = SYM2ID(variant);
1570
+ ID modern_id = rb_intern("modern");
1571
+ int use_modern_syntax = (variant_id == modern_id) ? 1 : 0;
1572
+
1573
+ // Get the @rules array from the stylesheet
1574
+ // @rules is an Array of Rule structs
1575
+ VALUE rules = rb_ivar_get(self, rb_intern("@rules"));
1576
+
1577
+ if (NIL_P(rules)) {
1578
+ return self; // No rules, nothing to convert
1579
+ }
1580
+
1581
+ if (TYPE(rules) != T_ARRAY) {
1582
+ rb_raise(rb_eTypeError, "Stylesheet @rules must be an Array, got %s",
1583
+ rb_obj_classname(rules));
1584
+ }
1585
+
1586
+ // Iterate through each rule
1587
+ long rules_count = RARRAY_LEN(rules);
1588
+
1589
+ for (long i = 0; i < rules_count; i++) {
1590
+ VALUE rule = rb_ary_entry(rules, i);
1591
+
1592
+ // Get declarations array from the rule struct
1593
+ // Rule = Struct.new(:id, :selector, :declarations, :specificity)
1594
+ // where declarations is an Array of Declaration structs
1595
+ VALUE declarations = rb_struct_aref(rule, INT2FIX(RULE_DECLARATIONS));
1596
+
1597
+ if (NIL_P(declarations) || TYPE(declarations) != T_ARRAY) {
1598
+ continue;
1599
+ }
1600
+
1601
+ // Iterate through each Declaration struct in the array
1602
+ long decl_count = RARRAY_LEN(declarations);
1603
+
1604
+ // Build new declarations array with expanded and converted values
1605
+ VALUE new_declarations = rb_ary_new();
1606
+
1607
+ for (long j = 0; j < decl_count; j++) {
1608
+ VALUE decl_struct = rb_ary_entry(declarations, j);
1609
+
1610
+ // Declaration = Struct.new(:property, :value, :important)
1611
+ VALUE property = rb_struct_aref(decl_struct, INT2FIX(DECL_PROPERTY));
1612
+ VALUE value = rb_struct_aref(decl_struct, INT2FIX(DECL_VALUE));
1613
+ VALUE important = rb_struct_aref(decl_struct, INT2FIX(DECL_IMPORTANT));
1614
+
1615
+ if (NIL_P(value) || TYPE(value) != T_STRING) {
1616
+ rb_ary_push(new_declarations, decl_struct);
1617
+ continue;
1618
+ }
1619
+
1620
+ // Check if this property needs expansion (e.g., background shorthand)
1621
+ VALUE expanded = expand_property_if_needed(property, value);
1622
+
1623
+ if (!NIL_P(expanded) && TYPE(expanded) == T_HASH && RHASH_SIZE(expanded) > 0) {
1624
+ // Expand and convert each sub-property
1625
+ struct expand_convert_context exp_ctx = {
1626
+ .parser = parser,
1627
+ .formatter = formatter,
1628
+ .use_modern_syntax = use_modern_syntax,
1629
+ .from_format = from_format,
1630
+ .new_declarations = new_declarations,
1631
+ .important = important
1632
+ };
1633
+ rb_hash_foreach(expanded, convert_expanded_property_callback, (VALUE)&exp_ctx);
1634
+ continue;
1635
+ }
1636
+ // If expansion returned empty hash, keep original declaration (e.g., gradients)
1637
+
1638
+ // Try to convert as a value with potentially multiple colors
1639
+ // (e.g., "border-color: #fff #000 #ccc" or "box-shadow: 0 0 10px #ff0000")
1640
+ VALUE converted_multi = convert_value_with_colors(value, parser, formatter, use_modern_syntax);
1641
+
1642
+ if (!NIL_P(converted_multi)) {
1643
+ DEBUG_PRINTF("Creating new decl with property='%s' value='%s'\n",
1644
+ StringValueCStr(property), StringValueCStr(converted_multi));
1645
+ // Successfully converted multi-value property
1646
+ VALUE new_decl = rb_struct_new(cDeclaration, property, converted_multi, important, NULL);
1647
+ rb_ary_push(new_declarations, new_decl);
1648
+ DEBUG_PRINTF("Pushed new_decl to new_declarations\n");
1649
+ continue;
1650
+ }
1651
+
1652
+ // Try single-value color conversion
1653
+ color_parser_fn single_parser;
1654
+
1655
+ // Auto-detect or use specified format
1656
+ if (parser == NULL) {
1657
+ single_parser = detect_color_format(value);
1658
+ } else if (matches_color_format(value, from_format)) {
1659
+ single_parser = parser;
1660
+ } else {
1661
+ single_parser = NULL;
1662
+ }
1663
+
1664
+ if (single_parser != NULL) {
1665
+ // Parse → IR → Format
1666
+ struct color_ir color = single_parser(value);
1667
+
1668
+ // Check if parse was successful (named colors can fail lookup)
1669
+ if (color.red < 0) {
1670
+ // Invalid color - keep original declaration
1671
+ rb_ary_push(new_declarations, decl_struct);
1672
+ } else {
1673
+ VALUE new_value = formatter(color, use_modern_syntax);
1674
+ VALUE new_decl = rb_struct_new(cDeclaration, property, new_value, important, NULL);
1675
+ rb_ary_push(new_declarations, new_decl);
1676
+ }
1677
+ } else {
1678
+ rb_ary_push(new_declarations, decl_struct);
1679
+ }
1680
+ }
1681
+
1682
+ // Replace rule's declarations array
1683
+ rb_struct_aset(rule, INT2FIX(RULE_DECLARATIONS), new_declarations);
1684
+ }
1685
+
1686
+ return self; // Return self for chaining
1687
+ }