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 +4 -4
- data/CHANGELOG.md +36 -1
- data/README.md +74 -29
- data/lib/silk_layout/css/cascade.rb +5 -2
- data/lib/silk_layout/css/color.rb +415 -0
- data/lib/silk_layout/css/computed_style.rb +43 -11
- data/lib/silk_layout/css/page_rule.rb +104 -0
- data/lib/silk_layout/css/parser.rb +122 -27
- data/lib/silk_layout/css/properties.rb +301 -3
- data/lib/silk_layout/css/selector.rb +246 -30
- data/lib/silk_layout/css/values.rb +169 -0
- data/lib/silk_layout/html/node.rb +1 -0
- data/lib/silk_layout/html/parser.rb +17 -3
- data/lib/silk_layout/layout/block_layout.rb +131 -12
- data/lib/silk_layout/layout/box.rb +39 -1
- data/lib/silk_layout/layout/context.rb +7 -2
- data/lib/silk_layout/layout/engine.rb +12 -2
- data/lib/silk_layout/layout/flex_layout.rb +1 -0
- data/lib/silk_layout/layout/formatting_builder.rb +73 -19
- data/lib/silk_layout/layout/inline_formatter.rb +88 -10
- data/lib/silk_layout/layout/root.rb +4 -5
- data/lib/silk_layout/render/painter.rb +17 -23
- data/lib/silk_layout/render/pdf_renderer.rb +39 -5
- data/lib/silk_layout/resource/image.rb +151 -0
- data/lib/silk_layout/version.rb +1 -1
- data/lib/silk_layout.rb +54 -4
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: eaabe9a16fb9f8239faf12994d4dbb5e55e7fa057a002b2e63697cbbd78c1d45
|
|
4
|
+
data.tar.gz: 286bebded7bfd70477c787f88be61e44d6589776c8fdaf2ee2c8e95db75b3ab9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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.
|
|
7
|
-
release track: useful for experimentation, regression testing, and early
|
|
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
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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/
|
|
134
|
-
the gem through Bundler's release task, and creates a GitHub release
|
|
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
|
|
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
|