silk_layout 0.1.0 → 0.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 98125df0dda9b67eb2040377c0d095ed91b33e2f62b944643c57565ba2c45cf3
4
- data.tar.gz: b19305f2b4f6e72d5a438ff299f0b6c5378eecda536436c865d10c8b82ac93fb
3
+ metadata.gz: eaabe9a16fb9f8239faf12994d4dbb5e55e7fa057a002b2e63697cbbd78c1d45
4
+ data.tar.gz: 286bebded7bfd70477c787f88be61e44d6589776c8fdaf2ee2c8e95db75b3ab9
5
5
  SHA512:
6
- metadata.gz: 9d12cfbb00fdfa50249fe3890ffc8c2d0742975089c8e19562e23fbff2a7055f4b52fee5af7a203fcf34ef5e195eb4d7f27b5f365484eef49e5a5d787489a7d1
7
- data.tar.gz: 50e90dbf3aa62fb150b028925bc8de3636c0fa54654c4ae68aedcd3eb9cd1b2187680b3ba90cc9cca2bff7557763e39eb3c6ec9eaaa973badcef0caebf896ed0
6
+ metadata.gz: ccc56be016419055cc8f6e3de5509917f5cfa9578d798bb87c400e3384bdd26bf9b5eee2a16c60b29bfcf41a1669d66f0167fcf2f66fcbd82c0efc6706da0433
7
+ data.tar.gz: f47d7dd848f5f373042752c257057d02972d758ee9dba7cea21761f4a268b225ed86e4a3d6b06d81cbcdb2a026c5f51b29f6eba80137e375861328c583ce49fd
data/CHANGELOG.md CHANGED
@@ -6,7 +6,42 @@ This project follows a lightweight changelog format while it is pre-1.0.
6
6
 
7
7
  ## Unreleased
8
8
 
9
- No changes yet.
9
+ - Nothing yet.
10
+
11
+ ## 0.2.0
12
+
13
+ Released rendering foundations for the 0.2 line.
14
+
15
+ ### Runtime Scope
16
+
17
+ - Local raster image loading, intrinsic image sizing, and PDF rendering smoke
18
+ coverage.
19
+ - CSS value hardening for common percent and `calc()` length cases.
20
+ - Richer color parsing for named colors, hex colors, `rgb()`, `rgba()`,
21
+ `hsl()`, `hsla()`, and `transparent`.
22
+ - Basic print CSS page sizing through explicit options and `@page size`.
23
+ - Continued cascade, selector, box model, inline layout, flex layout, and visual
24
+ parity hardening.
25
+
26
+ ### Release and DX
27
+
28
+ - Document the 0.2 support matrix as current, partial, or unsupported rather
29
+ than browser-complete.
30
+ - Document unit test, visual regression, single visual fixture, and package smoke
31
+ commands in the README and contribution guide.
32
+ - Add `bundle exec rake package:smoke` for a build/install/require/render smoke
33
+ path.
34
+ - Add CI coverage for the package smoke path on the release Ruby.
35
+
36
+ ### Known gaps
37
+
38
+ - Pagination, page-break controls, repeated headers/footers, and multi-page
39
+ fragmentation remain unsupported.
40
+ - CSS Grid, positioning, floats, tables, and list markers remain unsupported.
41
+ - Border radius, shadows, gradients, background images, filters, transforms, and
42
+ blend modes remain unsupported.
43
+ - Remote images, SVG, animated image formats, `object-fit`, and
44
+ `object-position` remain unsupported.
10
45
 
11
46
  ## 0.1.0
12
47
 
data/README.md CHANGED
@@ -3,35 +3,61 @@
3
3
  SilkLayout is a Ruby-native HTML/CSS layout engine that renders documents to PDF.
4
4
 
5
5
  The long-term goal is to make PDF generation feel natural inside Ruby apps without
6
- delegating layout to a browser engine. The project is currently on the `0.1`
7
- release track: useful for experimentation, regression testing, and early document
8
- rendering, but not yet 1.0 or browser-spec complete.
6
+ delegating layout to a browser engine. The project is currently on the `0.2`
7
+ pre-release track: useful for experimentation, regression testing, and early
8
+ document rendering, but not yet 1.0 or browser-spec complete.
9
9
 
10
10
  ## Current Status
11
11
 
12
- SilkLayout currently supports:
13
-
14
- - HTML parsing with Nokogiri
15
- - CSS parsing with Crass
16
- - CSS cascade basics, selector matching, specificity, inheritance, inline styles,
17
- and `!important`
18
- - block layout, inline text layout, line wrapping, and whitespace normalization
19
- - box model spacing: margin, padding, borders, border colors, width, and height
20
- - basic flex layout: rows, columns, reverse directions, wrapping, gaps, grow,
21
- shrink, basis, justify, and align basics
22
- - PDF rendering with HexaPDF for text, borders, border colors, and simple
23
- background colors
24
- - visual regression tests against Chromium output
25
-
26
- Not yet supported:
27
-
28
- - CSS Grid
29
- - percentage and `calc()` sizing
30
- - images
31
- - pagination and page-break controls
32
- - positioning, floats, tables, and list markers
33
- - border radius, shadows, gradients, and background images
34
- - full CSS color syntax such as `rgb()`, `rgba()`, and `hsl()`
12
+ SilkLayout is pre-1.0, so support is described as current, partial, or
13
+ unsupported rather than as a browser compatibility promise.
14
+
15
+ | Area | 0.2 status | Notes |
16
+ | --- | --- | --- |
17
+ | HTML parsing | Current | Nokogiri document parsing, text nodes, attributes, inline styles, `<style>` blocks, linked stylesheets, and base URLs. |
18
+ | CSS cascade | Current | Crass parsing, selector matching, specificity, inheritance, inline styles, and `!important`; unsupported selectors fail closed. |
19
+ | Block and inline layout | Current | Block stacking, inline text measurement, line wrapping, whitespace normalization, margins, padding, borders, width, and height. |
20
+ | Flex layout | Partial | Rows, columns, reverse directions, wrapping, gaps, grow, shrink, basis, justify, and align basics; full flexbox parity is still out of scope. |
21
+ | CSS values and colors | Partial / 0.2 target | Pixel lengths are current; 0.2 tracks percent and `calc()` lengths for common box values plus named, hex, `rgb()`, `rgba()`, `hsl()`, `hsla()`, and `transparent` colors. |
22
+ | PDF painting | Partial | Text, borders, border colors, and simple backgrounds are current; 0.2 tracks local raster image painting and print page sizing. |
23
+ | Images | Partial / 0.2 target | Local image loading, intrinsic sizing, and PDF smoke coverage are planned for 0.2; remote images, SVG, animated formats, `object-fit`, and `object-position` are unsupported. |
24
+ | Visual regression | Current | Chromium reference PDF rendering with PNG comparison; generated artifacts are written to `tmp/visual/`. |
25
+ | Pagination and fragmentation | Unsupported | Page breaks, repeated headers/footers, and content overflow across pages are before-1.0 work. |
26
+ | Advanced layout | Unsupported | CSS Grid, positioning, floats, tables, and list markers are not implemented yet. |
27
+ | Advanced painting | Unsupported | Border radius, shadows, gradients, background images, filters, and transforms are not implemented yet. |
28
+
29
+ ## Example-Driven Roadmap
30
+
31
+ The next rendering milestones are anchored to realistic document families rather
32
+ than abstract CSS checklists. The comparison fixtures in `tmp/pdf_compare/`
33
+ currently show a browser/Chromium render next to SilkLayout output for each
34
+ target. They are temporary evaluation artifacts, but the document types below are
35
+ the product goals they represent.
36
+
37
+ | Goal document | Current fit | Feature work needed |
38
+ | --- | --- | --- |
39
+ | Plain invoices and receipt-like statements | Closest near-term target. Simple text, spacing, borders, page sizing, and gray bands are mostly in range. | Table layout or robust table-like rows; reliable numeric column alignment; better font-weight handling; footer placement; tighter support for absolute/relative positioning used by headers and payment blocks. |
40
+ | Colorful invoices and branded receipts | Partially reachable when simplified, but decorative layout falls apart quickly. | Stable flex for document components; border radius; layered backgrounds; background images; gradients; better positioned panels; custom or mapped font weights; table row styling including alternating fills. |
41
+ | Government notices and simple pamphlets | Good medium-term target for one-page notices; multi-page publications remain limited. | Multi-column layout; pagination and fragmentation; repeated headers/footers; page counters; column rules; improved text sizing/leading parity; better page-margin and print CSS handling. |
42
+ | Posters and flyers | Useful stress target, but not visually faithful yet. | Absolute positioning; transforms/rotation; border radius and circles; dashed borders; SVG/vector image support; background images/textures; object fitting and image clipping; richer z-index/layering. |
43
+ | Brochures and park/map guides | Long-range target because these combine nearly every hard print feature. | Multi-page spreads; CSS Grid; floats; positioned overlays; clipping/masking; remote and local raster image scaling; SVG/vector paths; map/icon rendering; gradients and blend-like background effects; robust small-text layout. |
44
+
45
+ Cross-cutting engine work that serves all of these goals:
46
+
47
+ - Stabilize font resolution and weight/style mapping so common CSS such as
48
+ `font-weight: bold` never crashes PDF rendering.
49
+ - Make partial flexbox behavior predictable for document layout, then add
50
+ regression fixtures for invoice headers, side panels, and totals blocks.
51
+ - Add a first-class table layout path before treating real invoices or receipts
52
+ as supported.
53
+ - Add paged-media primitives: page breaks, content overflow across pages,
54
+ repeated headers/footers, and page counters.
55
+ - Expand the painter beyond rectangular fills and straight borders: rounded
56
+ corners, dashed borders, gradients, background images, and image clipping.
57
+ - Support SVG and remote images so real logos, badges, maps, QR codes, and
58
+ brochure artwork can be rendered without pre-flattening.
59
+ - Keep browser-vs-SilkLayout visual comparison fixtures for each goal document
60
+ and graduate them into the visual regression suite as features land.
35
61
 
36
62
  ## Installation
37
63
 
@@ -104,6 +130,12 @@ Run tests only:
104
130
  bundle exec rake test
105
131
  ```
106
132
 
133
+ Run visual regression tests:
134
+
135
+ ```sh
136
+ BROWSER_PATH=/path/to/chrome bundle exec ruby -Ilib:test test/visual_test.rb
137
+ ```
138
+
107
139
  Run lint:
108
140
 
109
141
  ```sh
@@ -116,6 +148,19 @@ Run a single test file:
116
148
  bundle exec ruby -Ilib:test test/layout/flex_layout_test.rb
117
149
  ```
118
150
 
151
+ Run a single visual fixture:
152
+
153
+ ```sh
154
+ BROWSER_PATH=/path/to/chrome bundle exec ruby -Ilib:test test/visual_test.rb -n test_visual_flex_row_basic
155
+ ```
156
+
157
+ Build and install the gem into a temporary `GEM_HOME`, then require it and render
158
+ a smoke PDF from the installed package:
159
+
160
+ ```sh
161
+ bundle exec rake package:smoke
162
+ ```
163
+
119
164
  Visual regression tests require Chromium or Chrome plus PDF-to-PNG tooling such
120
165
  as Poppler or ImageMagick. Generated visual artifacts are written to
121
166
  `tmp/visual/`.
@@ -130,9 +175,9 @@ script/release
130
175
  ```
131
176
 
132
177
  The script must be run from a clean, up-to-date `main` branch after the release
133
- PR is merged. It installs dependencies, runs the test/lint/build gate, publishes
134
- the gem through Bundler's release task, and creates a GitHub release from the
135
- matching `CHANGELOG.md` section.
178
+ PR is merged. It installs dependencies, runs the test/lint/package smoke gate,
179
+ publishes the gem through Bundler's release task, and creates a GitHub release
180
+ from the matching `CHANGELOG.md` section.
136
181
 
137
182
  This gem is still pre-1.0. Before a public 1.0 release, the project should
138
183
  stabilize the supported CSS surface, add pagination, improve visual parity, and
@@ -29,11 +29,14 @@ module SilkLayout
29
29
  style = node.attributes["style"].to_s.strip
30
30
  return nil if style.empty?
31
31
 
32
- declarations = {}
32
+ declarations = []
33
33
  Crass.parse_properties(style).each do |child|
34
34
  next unless child[:node] == :property
35
35
 
36
- declarations[child[:name]] = Declaration.new(value: child[:value], important: child[:important] ? true : false)
36
+ declarations << [
37
+ child[:name].to_s.downcase,
38
+ Declaration.new(value: child[:value].to_s.strip, important: child[:important] ? true : false)
39
+ ]
37
40
  end
38
41
 
39
42
  Rule.new(
@@ -0,0 +1,415 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SilkLayout
4
+ module CSS
5
+ class Color
6
+ attr_reader :red,
7
+ :green,
8
+ :blue,
9
+ :alpha,
10
+ :normalized
11
+
12
+ NAMED_COLOR_HEX = {
13
+ "aliceblue" => "f0f8ff",
14
+ "antiquewhite" => "faebd7",
15
+ "aqua" => "00ffff",
16
+ "aquamarine" => "7fffd4",
17
+ "azure" => "f0ffff",
18
+ "beige" => "f5f5dc",
19
+ "bisque" => "ffe4c4",
20
+ "black" => "000000",
21
+ "blanchedalmond" => "ffebcd",
22
+ "blue" => "0000ff",
23
+ "blueviolet" => "8a2be2",
24
+ "brown" => "a52a2a",
25
+ "burlywood" => "deb887",
26
+ "cadetblue" => "5f9ea0",
27
+ "chartreuse" => "7fff00",
28
+ "chocolate" => "d2691e",
29
+ "coral" => "ff7f50",
30
+ "cornflowerblue" => "6495ed",
31
+ "cornsilk" => "fff8dc",
32
+ "crimson" => "dc143c",
33
+ "cyan" => "00ffff",
34
+ "darkblue" => "00008b",
35
+ "darkcyan" => "008b8b",
36
+ "darkgoldenrod" => "b8860b",
37
+ "darkgray" => "a9a9a9",
38
+ "darkgreen" => "006400",
39
+ "darkgrey" => "a9a9a9",
40
+ "darkkhaki" => "bdb76b",
41
+ "darkmagenta" => "8b008b",
42
+ "darkolivegreen" => "556b2f",
43
+ "darkorange" => "ff8c00",
44
+ "darkorchid" => "9932cc",
45
+ "darkred" => "8b0000",
46
+ "darksalmon" => "e9967a",
47
+ "darkseagreen" => "8fbc8f",
48
+ "darkslateblue" => "483d8b",
49
+ "darkslategray" => "2f4f4f",
50
+ "darkslategrey" => "2f4f4f",
51
+ "darkturquoise" => "00ced1",
52
+ "darkviolet" => "9400d3",
53
+ "deeppink" => "ff1493",
54
+ "deepskyblue" => "00bfff",
55
+ "dimgray" => "696969",
56
+ "dimgrey" => "696969",
57
+ "dodgerblue" => "1e90ff",
58
+ "firebrick" => "b22222",
59
+ "floralwhite" => "fffaf0",
60
+ "forestgreen" => "228b22",
61
+ "fuchsia" => "ff00ff",
62
+ "gainsboro" => "dcdcdc",
63
+ "ghostwhite" => "f8f8ff",
64
+ "gold" => "ffd700",
65
+ "goldenrod" => "daa520",
66
+ "gray" => "808080",
67
+ "green" => "008000",
68
+ "greenyellow" => "adff2f",
69
+ "grey" => "808080",
70
+ "honeydew" => "f0fff0",
71
+ "hotpink" => "ff69b4",
72
+ "indianred" => "cd5c5c",
73
+ "indigo" => "4b0082",
74
+ "ivory" => "fffff0",
75
+ "khaki" => "f0e68c",
76
+ "lavender" => "e6e6fa",
77
+ "lavenderblush" => "fff0f5",
78
+ "lawngreen" => "7cfc00",
79
+ "lemonchiffon" => "fffacd",
80
+ "lightblue" => "add8e6",
81
+ "lightcoral" => "f08080",
82
+ "lightcyan" => "e0ffff",
83
+ "lightgoldenrodyellow" => "fafad2",
84
+ "lightgray" => "d3d3d3",
85
+ "lightgreen" => "90ee90",
86
+ "lightgrey" => "d3d3d3",
87
+ "lightpink" => "ffb6c1",
88
+ "lightsalmon" => "ffa07a",
89
+ "lightseagreen" => "20b2aa",
90
+ "lightskyblue" => "87cefa",
91
+ "lightslategray" => "778899",
92
+ "lightslategrey" => "778899",
93
+ "lightsteelblue" => "b0c4de",
94
+ "lightyellow" => "ffffe0",
95
+ "lime" => "00ff00",
96
+ "limegreen" => "32cd32",
97
+ "linen" => "faf0e6",
98
+ "magenta" => "ff00ff",
99
+ "maroon" => "800000",
100
+ "mediumaquamarine" => "66cdaa",
101
+ "mediumblue" => "0000cd",
102
+ "mediumorchid" => "ba55d3",
103
+ "mediumpurple" => "9370db",
104
+ "mediumseagreen" => "3cb371",
105
+ "mediumslateblue" => "7b68ee",
106
+ "mediumspringgreen" => "00fa9a",
107
+ "mediumturquoise" => "48d1cc",
108
+ "mediumvioletred" => "c71585",
109
+ "midnightblue" => "191970",
110
+ "mintcream" => "f5fffa",
111
+ "mistyrose" => "ffe4e1",
112
+ "moccasin" => "ffe4b5",
113
+ "navajowhite" => "ffdead",
114
+ "navy" => "000080",
115
+ "oldlace" => "fdf5e6",
116
+ "olive" => "808000",
117
+ "olivedrab" => "6b8e23",
118
+ "orange" => "ffa500",
119
+ "orangered" => "ff4500",
120
+ "orchid" => "da70d6",
121
+ "palegoldenrod" => "eee8aa",
122
+ "palegreen" => "98fb98",
123
+ "paleturquoise" => "afeeee",
124
+ "palevioletred" => "db7093",
125
+ "papayawhip" => "ffefd5",
126
+ "peachpuff" => "ffdab9",
127
+ "peru" => "cd853f",
128
+ "pink" => "ffc0cb",
129
+ "plum" => "dda0dd",
130
+ "powderblue" => "b0e0e6",
131
+ "purple" => "800080",
132
+ "rebeccapurple" => "663399",
133
+ "red" => "ff0000",
134
+ "rosybrown" => "bc8f8f",
135
+ "royalblue" => "4169e1",
136
+ "saddlebrown" => "8b4513",
137
+ "salmon" => "fa8072",
138
+ "sandybrown" => "f4a460",
139
+ "seagreen" => "2e8b57",
140
+ "seashell" => "fff5ee",
141
+ "sienna" => "a0522d",
142
+ "silver" => "c0c0c0",
143
+ "skyblue" => "87ceeb",
144
+ "slateblue" => "6a5acd",
145
+ "slategray" => "708090",
146
+ "slategrey" => "708090",
147
+ "snow" => "fffafa",
148
+ "springgreen" => "00ff7f",
149
+ "steelblue" => "4682b4",
150
+ "tan" => "d2b48c",
151
+ "teal" => "008080",
152
+ "thistle" => "d8bfd8",
153
+ "tomato" => "ff6347",
154
+ "turquoise" => "40e0d0",
155
+ "violet" => "ee82ee",
156
+ "wheat" => "f5deb3",
157
+ "white" => "ffffff",
158
+ "whitesmoke" => "f5f5f5",
159
+ "yellow" => "ffff00",
160
+ "yellowgreen" => "9acd32"
161
+ }.freeze
162
+
163
+ NAMED_COLORS = NAMED_COLOR_HEX.transform_values do |hex|
164
+ hex.scan(/../).map { |component| component.to_i(16) }
165
+ end.freeze
166
+
167
+ FUNCTION_PATTERN = /\A([a-z]+)\((.*)\)\z/
168
+ NUMBER_PATTERN = /\A[-+]?(?:\d+\.?\d*|\.\d+)\z/
169
+
170
+ def self.parse(value)
171
+ return value if value.is_a?(self)
172
+
173
+ raw = value.to_s.strip
174
+ return nil if raw.empty?
175
+
176
+ normalized = raw.downcase
177
+ return new(0, 0, 0, alpha: 0, normalized: "transparent") if normalized == "transparent"
178
+
179
+ if (rgb = NAMED_COLORS[normalized])
180
+ return new(*rgb, normalized: normalized)
181
+ end
182
+
183
+ return parse_hex(normalized) if normalized.start_with?("#")
184
+
185
+ match = normalized.match(FUNCTION_PATTERN)
186
+ return nil unless match
187
+
188
+ case match[1]
189
+ when "rgb", "rgba"
190
+ parse_rgb_function(match[1], match[2], normalized)
191
+ when "hsl", "hsla"
192
+ parse_hsl_function(match[1], match[2], normalized)
193
+ end
194
+ end
195
+
196
+ # The renderer currently paints RGB only. Alpha is kept on parsed colors;
197
+ # conversion treats fully transparent colors as not paintable and ignores
198
+ # partial alpha as a best-effort fallback.
199
+ def self.rgb(value)
200
+ color = parse(value)
201
+ return nil unless color
202
+ return nil if color.transparent?
203
+
204
+ color.rgb
205
+ end
206
+
207
+ def initialize(red, green, blue, normalized:, alpha: 1.0)
208
+ @red = clamp_byte(red)
209
+ @green = clamp_byte(green)
210
+ @blue = clamp_byte(blue)
211
+ @alpha = clamp_unit(alpha)
212
+ @normalized = normalized
213
+ end
214
+
215
+ def rgb
216
+ [red, green, blue]
217
+ end
218
+
219
+ def transparent?
220
+ alpha <= 0
221
+ end
222
+
223
+ def to_sym
224
+ normalized.to_sym
225
+ end
226
+
227
+ def to_s
228
+ normalized
229
+ end
230
+
231
+ def self.parse_hex(value)
232
+ hex = value.delete_prefix("#")
233
+ return nil unless hex.match?(/\A(?:[0-9a-f]{3}|[0-9a-f]{6})\z/)
234
+
235
+ expanded = (hex.length == 3) ? hex.chars.flat_map { |char| [char, char] }.join : hex
236
+ new(*expanded.scan(/../).map { |component| component.to_i(16) }, normalized: value)
237
+ end
238
+ private_class_method :parse_hex
239
+
240
+ def self.parse_rgb_function(name, content, normalized)
241
+ components = split_function_arguments(content)
242
+ return nil unless [3, 4].include?(components.length)
243
+ return nil if name == "rgba" && components.length < 4
244
+
245
+ red = parse_rgb_component(components[0])
246
+ green = parse_rgb_component(components[1])
247
+ blue = parse_rgb_component(components[2])
248
+ alpha = components[3] ? parse_alpha(components[3]) : 1
249
+ return nil if [red, green, blue, alpha].any?(&:nil?)
250
+
251
+ new(red, green, blue, alpha: alpha, normalized: normalized)
252
+ end
253
+ private_class_method :parse_rgb_function
254
+
255
+ def self.parse_hsl_function(name, content, normalized)
256
+ components = split_function_arguments(content)
257
+ return nil unless [3, 4].include?(components.length)
258
+ return nil if name == "hsla" && components.length < 4
259
+
260
+ hue = parse_hue(components[0])
261
+ saturation = parse_percentage(components[1])
262
+ lightness = parse_percentage(components[2])
263
+ alpha = components[3] ? parse_alpha(components[3]) : 1
264
+ return nil if [hue, saturation, lightness, alpha].any?(&:nil?)
265
+
266
+ new(*hsl_to_rgb(hue, saturation, lightness), alpha: alpha, normalized: normalized)
267
+ end
268
+ private_class_method :parse_hsl_function
269
+
270
+ def self.split_function_arguments(content)
271
+ raw = content.to_s.strip
272
+ return [] if raw.empty?
273
+
274
+ parts =
275
+ if raw.include?(",")
276
+ raw.split(",").map(&:strip)
277
+ else
278
+ values, alpha = raw.split("/", 2).map(&:strip)
279
+ values.to_s.split(/\s+/).tap { |items| items << alpha if alpha }
280
+ end
281
+
282
+ if parts.last&.include?("/")
283
+ value, alpha = parts.pop.split("/", 2).map(&:strip)
284
+ parts << value
285
+ parts << alpha
286
+ end
287
+
288
+ parts
289
+ end
290
+ private_class_method :split_function_arguments
291
+
292
+ def self.parse_rgb_component(value)
293
+ raw = value.to_s.strip
294
+ if raw.end_with?("%")
295
+ percentage = parse_number(raw.delete_suffix("%"))
296
+ return nil unless percentage
297
+
298
+ clamp_byte(percentage * 255 / 100.0)
299
+ else
300
+ number = parse_number(raw)
301
+ return nil unless number
302
+
303
+ clamp_byte(number)
304
+ end
305
+ end
306
+ private_class_method :parse_rgb_component
307
+
308
+ def self.parse_alpha(value)
309
+ raw = value.to_s.strip
310
+ if raw.end_with?("%")
311
+ percentage = parse_number(raw.delete_suffix("%"))
312
+ return nil unless percentage
313
+
314
+ clamp_unit(percentage / 100.0)
315
+ else
316
+ number = parse_number(raw)
317
+ return nil unless number
318
+
319
+ clamp_unit(number)
320
+ end
321
+ end
322
+ private_class_method :parse_alpha
323
+
324
+ def self.parse_hue(value)
325
+ raw = value.to_s.strip
326
+ multiplier =
327
+ if raw.end_with?("turn")
328
+ raw = raw.delete_suffix("turn")
329
+ 360
330
+ elsif raw.end_with?("deg")
331
+ raw = raw.delete_suffix("deg")
332
+ 1
333
+ elsif raw.end_with?("rad")
334
+ raw = raw.delete_suffix("rad")
335
+ 180 / Math::PI
336
+ else
337
+ 1
338
+ end
339
+
340
+ number = parse_number(raw)
341
+ return nil unless number
342
+
343
+ (number * multiplier) % 360
344
+ end
345
+ private_class_method :parse_hue
346
+
347
+ def self.parse_percentage(value)
348
+ raw = value.to_s.strip
349
+ return nil unless raw.end_with?("%")
350
+
351
+ number = parse_number(raw.delete_suffix("%"))
352
+ return nil unless number
353
+
354
+ clamp_unit(number / 100.0)
355
+ end
356
+ private_class_method :parse_percentage
357
+
358
+ def self.parse_number(value)
359
+ raw = value.to_s.strip
360
+ return nil unless raw.match?(NUMBER_PATTERN)
361
+
362
+ raw.to_f
363
+ end
364
+ private_class_method :parse_number
365
+
366
+ def self.hsl_to_rgb(hue, saturation, lightness)
367
+ chroma = (1 - ((2 * lightness) - 1).abs) * saturation
368
+ hue_segment = hue / 60.0
369
+ x = chroma * (1 - ((hue_segment % 2) - 1).abs)
370
+ match = lightness - (chroma / 2)
371
+
372
+ red, green, blue =
373
+ case hue_segment
374
+ when 0...1
375
+ [chroma, x, 0]
376
+ when 1...2
377
+ [x, chroma, 0]
378
+ when 2...3
379
+ [0, chroma, x]
380
+ when 3...4
381
+ [0, x, chroma]
382
+ when 4...5
383
+ [x, 0, chroma]
384
+ else
385
+ [chroma, 0, x]
386
+ end
387
+
388
+ [
389
+ clamp_byte((red + match) * 255),
390
+ clamp_byte((green + match) * 255),
391
+ clamp_byte((blue + match) * 255)
392
+ ]
393
+ end
394
+ private_class_method :hsl_to_rgb
395
+
396
+ def self.clamp_byte(value)
397
+ value.to_f.round.clamp(0, 255)
398
+ end
399
+ private_class_method :clamp_byte
400
+
401
+ def self.clamp_unit(value)
402
+ value.to_f.clamp(0, 1)
403
+ end
404
+ private_class_method :clamp_unit
405
+
406
+ def clamp_byte(value)
407
+ value.to_f.round.clamp(0, 255)
408
+ end
409
+
410
+ def clamp_unit(value)
411
+ value.to_f.clamp(0, 1)
412
+ end
413
+ end
414
+ end
415
+ end