silk_layout 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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +20 -0
- data/LICENSE +21 -0
- data/README.md +154 -0
- data/lib/silk_layout/css/cascade.rb +49 -0
- data/lib/silk_layout/css/computed_style.rb +83 -0
- data/lib/silk_layout/css/parser.rb +50 -0
- data/lib/silk_layout/css/properties.rb +19 -0
- data/lib/silk_layout/css/rule.rb +15 -0
- data/lib/silk_layout/css/selector.rb +165 -0
- data/lib/silk_layout/html/node.rb +56 -0
- data/lib/silk_layout/html/parser.rb +188 -0
- data/lib/silk_layout/layout/block_layout.rb +93 -0
- data/lib/silk_layout/layout/box.rb +80 -0
- data/lib/silk_layout/layout/box_builder.rb +11 -0
- data/lib/silk_layout/layout/context.rb +13 -0
- data/lib/silk_layout/layout/engine.rb +22 -0
- data/lib/silk_layout/layout/flex_layout.rb +508 -0
- data/lib/silk_layout/layout/formatting_builder.rb +425 -0
- data/lib/silk_layout/layout/inline.rb +88 -0
- data/lib/silk_layout/layout/inline_formatter.rb +132 -0
- data/lib/silk_layout/layout/root.rb +15 -0
- data/lib/silk_layout/render/font_library.rb +127 -0
- data/lib/silk_layout/render/painter.rb +247 -0
- data/lib/silk_layout/render/pdf_renderer.rb +31 -0
- data/lib/silk_layout/version.rb +5 -0
- data/lib/silk_layout.rb +55 -0
- metadata +251 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 98125df0dda9b67eb2040377c0d095ed91b33e2f62b944643c57565ba2c45cf3
|
|
4
|
+
data.tar.gz: b19305f2b4f6e72d5a438ff299f0b6c5378eecda536436c865d10c8b82ac93fb
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 9d12cfbb00fdfa50249fe3890ffc8c2d0742975089c8e19562e23fbff2a7055f4b52fee5af7a203fcf34ef5e195eb4d7f27b5f365484eef49e5a5d787489a7d1
|
|
7
|
+
data.tar.gz: 50e90dbf3aa62fb150b028925bc8de3636c0fa54654c4ae68aedcd3eb9cd1b2187680b3ba90cc9cca2bff7557763e39eb3c6ec9eaaa973badcef0caebf896ed0
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to SilkLayout will be documented here.
|
|
4
|
+
|
|
5
|
+
This project follows a lightweight changelog format while it is pre-1.0.
|
|
6
|
+
|
|
7
|
+
## Unreleased
|
|
8
|
+
|
|
9
|
+
No changes yet.
|
|
10
|
+
|
|
11
|
+
## 0.1.0
|
|
12
|
+
|
|
13
|
+
- Add public project README, MIT license, contribution guide, security policy,
|
|
14
|
+
and issue templates.
|
|
15
|
+
- Prepare repository metadata for public visibility.
|
|
16
|
+
- Ruby-native HTML/CSS parsing, layout, and PDF rendering foundation.
|
|
17
|
+
- Block and inline layout support.
|
|
18
|
+
- Box model support for margins, padding, borders, and backgrounds.
|
|
19
|
+
- Basic flex layout support.
|
|
20
|
+
- Visual regression tests against Chromium output.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Reegan Viljoen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# SilkLayout
|
|
2
|
+
|
|
3
|
+
SilkLayout is a Ruby-native HTML/CSS layout engine that renders documents to PDF.
|
|
4
|
+
|
|
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.
|
|
9
|
+
|
|
10
|
+
## Current Status
|
|
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()`
|
|
35
|
+
|
|
36
|
+
## Installation
|
|
37
|
+
|
|
38
|
+
Add the gem to your bundle once released:
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
gem "silk_layout"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
For local development from this repository:
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
bundle install
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Usage
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
require "silk_layout"
|
|
54
|
+
|
|
55
|
+
html = <<~HTML
|
|
56
|
+
<!doctype html>
|
|
57
|
+
<html>
|
|
58
|
+
<head>
|
|
59
|
+
<style>
|
|
60
|
+
body { font-family: Helvetica; margin: 0; }
|
|
61
|
+
.row {
|
|
62
|
+
display: flex;
|
|
63
|
+
gap: 16px;
|
|
64
|
+
padding: 16px;
|
|
65
|
+
border: 2px solid black;
|
|
66
|
+
}
|
|
67
|
+
.item {
|
|
68
|
+
width: 80px;
|
|
69
|
+
padding: 8px;
|
|
70
|
+
border: 1px solid blue;
|
|
71
|
+
background: lightblue;
|
|
72
|
+
}
|
|
73
|
+
</style>
|
|
74
|
+
</head>
|
|
75
|
+
<body>
|
|
76
|
+
<div class="row">
|
|
77
|
+
<div class="item">One</div>
|
|
78
|
+
<div class="item">Two</div>
|
|
79
|
+
</div>
|
|
80
|
+
</body>
|
|
81
|
+
</html>
|
|
82
|
+
HTML
|
|
83
|
+
|
|
84
|
+
SilkLayout.render_document(html, "out.pdf")
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Documentation
|
|
88
|
+
|
|
89
|
+
The documentation site lives in [`docs/`](docs/index.md). It includes the project
|
|
90
|
+
goal, current support matrix, before-1.0 roadmap, and Chromium-vs-SilkLayout
|
|
91
|
+
screenshots from the visual regression suite.
|
|
92
|
+
|
|
93
|
+
## Development
|
|
94
|
+
|
|
95
|
+
Run the full local gate:
|
|
96
|
+
|
|
97
|
+
```sh
|
|
98
|
+
bundle exec rake
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Run tests only:
|
|
102
|
+
|
|
103
|
+
```sh
|
|
104
|
+
bundle exec rake test
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Run lint:
|
|
108
|
+
|
|
109
|
+
```sh
|
|
110
|
+
bundle exec standardrb
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Run a single test file:
|
|
114
|
+
|
|
115
|
+
```sh
|
|
116
|
+
bundle exec ruby -Ilib:test test/layout/flex_layout_test.rb
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Visual regression tests require Chromium or Chrome plus PDF-to-PNG tooling such
|
|
120
|
+
as Poppler or ImageMagick. Generated visual artifacts are written to
|
|
121
|
+
`tmp/visual/`.
|
|
122
|
+
|
|
123
|
+
## Releasing
|
|
124
|
+
|
|
125
|
+
SilkLayout releases use the checked-in release script:
|
|
126
|
+
|
|
127
|
+
```sh
|
|
128
|
+
DRY_RUN=1 script/release
|
|
129
|
+
script/release
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
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.
|
|
136
|
+
|
|
137
|
+
This gem is still pre-1.0. Before a public 1.0 release, the project should
|
|
138
|
+
stabilize the supported CSS surface, add pagination, improve visual parity, and
|
|
139
|
+
document compatibility promises.
|
|
140
|
+
|
|
141
|
+
## Contributing
|
|
142
|
+
|
|
143
|
+
Contributions are welcome while the engine is young. Please read
|
|
144
|
+
[`CONTRIBUTING.md`](CONTRIBUTING.md) and open issues or PRs with focused examples
|
|
145
|
+
and, where possible, visual regression fixtures.
|
|
146
|
+
|
|
147
|
+
## Security
|
|
148
|
+
|
|
149
|
+
Please report security issues privately using the process in
|
|
150
|
+
[`SECURITY.md`](SECURITY.md).
|
|
151
|
+
|
|
152
|
+
## License
|
|
153
|
+
|
|
154
|
+
SilkLayout is available under the MIT License. See [`LICENSE`](LICENSE).
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "crass"
|
|
4
|
+
|
|
5
|
+
module SilkLayout
|
|
6
|
+
module CSS
|
|
7
|
+
class Cascade
|
|
8
|
+
def self.apply(node, rules, parent_style = nil)
|
|
9
|
+
return unless node
|
|
10
|
+
return unless node.respond_to?(:element?)
|
|
11
|
+
|
|
12
|
+
matching = rules.select { |rule| rule.selector.match?(node) }
|
|
13
|
+
|
|
14
|
+
inline = inline_rule(node)
|
|
15
|
+
matching << inline if inline
|
|
16
|
+
|
|
17
|
+
matching.sort_by! { |rule| [rule.specificity, rule.order] }
|
|
18
|
+
|
|
19
|
+
node.computed_style = ComputedStyle.new(matching, parent_style)
|
|
20
|
+
|
|
21
|
+
node.children.each do |child|
|
|
22
|
+
apply(child, rules, node.computed_style)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.inline_rule(node)
|
|
27
|
+
return nil unless node.attributes
|
|
28
|
+
|
|
29
|
+
style = node.attributes["style"].to_s.strip
|
|
30
|
+
return nil if style.empty?
|
|
31
|
+
|
|
32
|
+
declarations = {}
|
|
33
|
+
Crass.parse_properties(style).each do |child|
|
|
34
|
+
next unless child[:node] == :property
|
|
35
|
+
|
|
36
|
+
declarations[child[:name]] = Declaration.new(value: child[:value], important: child[:important] ? true : false)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
Rule.new(
|
|
40
|
+
selector: nil,
|
|
41
|
+
declarations: declarations,
|
|
42
|
+
specificity: [1000, 0, 0],
|
|
43
|
+
order: 1_000_000_000,
|
|
44
|
+
origin: :author
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SilkLayout
|
|
4
|
+
module CSS
|
|
5
|
+
class ComputedStyle
|
|
6
|
+
def initialize(rules, parent_style = nil)
|
|
7
|
+
@values = {}
|
|
8
|
+
@explicit_properties = {}
|
|
9
|
+
|
|
10
|
+
apply_rules(rules)
|
|
11
|
+
|
|
12
|
+
apply_inheritance(parent_style)
|
|
13
|
+
apply_defaults
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def [](property)
|
|
17
|
+
@values[property]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def width
|
|
21
|
+
@values["width"]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def explicit_width?
|
|
25
|
+
value = @values["width"]
|
|
26
|
+
value && value != "auto"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def explicit_height?
|
|
30
|
+
value = @values["height"]
|
|
31
|
+
value && value != "auto"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def explicit_display?
|
|
35
|
+
@explicit_properties.key?("display")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
INLINE_SPECIFICITY = [1000, 0, 0].freeze
|
|
41
|
+
|
|
42
|
+
def apply_rules(rules)
|
|
43
|
+
winners = {}
|
|
44
|
+
|
|
45
|
+
rules.each do |rule|
|
|
46
|
+
spec = rule.specificity || INLINE_SPECIFICITY
|
|
47
|
+
order = rule.order || 0
|
|
48
|
+
|
|
49
|
+
rule.declarations.each do |property, decl|
|
|
50
|
+
value = decl.is_a?(Declaration) ? decl.value : decl
|
|
51
|
+
important = decl.is_a?(Declaration) ? decl.important : false
|
|
52
|
+
|
|
53
|
+
key = [important ? 1 : 0, spec, order]
|
|
54
|
+
current = winners[property]
|
|
55
|
+
|
|
56
|
+
if current.nil? || (key <=> current[:key]) == 1
|
|
57
|
+
winners[property] = {key: key, value: value}
|
|
58
|
+
@explicit_properties[property] = true
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
winners.each do |prop, data|
|
|
64
|
+
@values[prop] = data[:value]
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def apply_inheritance(parent)
|
|
69
|
+
return unless parent
|
|
70
|
+
|
|
71
|
+
Properties::INHERITED.each do |prop|
|
|
72
|
+
@values[prop] ||= parent[prop]
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def apply_defaults
|
|
77
|
+
Properties::DEFAULTS.each do |prop, value|
|
|
78
|
+
@values[prop] ||= value
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "crass"
|
|
4
|
+
|
|
5
|
+
module SilkLayout
|
|
6
|
+
module CSS
|
|
7
|
+
class Parser
|
|
8
|
+
def self.parse_all(stylesheets)
|
|
9
|
+
rules = []
|
|
10
|
+
order = 0
|
|
11
|
+
|
|
12
|
+
stylesheets.each do |css|
|
|
13
|
+
Crass.parse(css).each do |node|
|
|
14
|
+
next unless node[:node] == :style_rule
|
|
15
|
+
|
|
16
|
+
selector_text = node[:selector][:value].strip
|
|
17
|
+
selectors = selector_text.split(",").map(&:strip)
|
|
18
|
+
|
|
19
|
+
declarations = {}
|
|
20
|
+
|
|
21
|
+
node[:children].each do |child|
|
|
22
|
+
next unless child[:node] == :property
|
|
23
|
+
|
|
24
|
+
property = child[:name]
|
|
25
|
+
value = child[:value]
|
|
26
|
+
|
|
27
|
+
declarations[property] = Declaration.new(value: value, important: child[:important] ? true : false)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
selectors.each do |raw_selector|
|
|
31
|
+
selector = Selector.new(raw_selector)
|
|
32
|
+
|
|
33
|
+
rules << Rule.new(
|
|
34
|
+
selector: selector,
|
|
35
|
+
declarations: declarations,
|
|
36
|
+
specificity: selector.specificity,
|
|
37
|
+
order: order,
|
|
38
|
+
origin: :author
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
order += 1
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
rules
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SilkLayout
|
|
4
|
+
module CSS
|
|
5
|
+
module Properties
|
|
6
|
+
INHERITED = %w[
|
|
7
|
+
color
|
|
8
|
+
font-size
|
|
9
|
+
font-family
|
|
10
|
+
].freeze
|
|
11
|
+
|
|
12
|
+
DEFAULTS = {
|
|
13
|
+
"color" => "black",
|
|
14
|
+
"font-size" => "16px",
|
|
15
|
+
"display" => "inline"
|
|
16
|
+
}.freeze
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SilkLayout
|
|
4
|
+
module CSS
|
|
5
|
+
class Selector
|
|
6
|
+
def initialize(raw)
|
|
7
|
+
@raw = raw.to_s.strip
|
|
8
|
+
@steps = parse_steps(@raw)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def match?(node)
|
|
12
|
+
return false unless node.respond_to?(:element?)
|
|
13
|
+
return false unless node.element?
|
|
14
|
+
return false if node.tag.nil?
|
|
15
|
+
|
|
16
|
+
return false unless step_match?(@steps.last[:simple], node)
|
|
17
|
+
|
|
18
|
+
current = node
|
|
19
|
+
i = @steps.length - 2
|
|
20
|
+
while i >= 0
|
|
21
|
+
comb = @steps[i + 1][:combinator]
|
|
22
|
+
simple = @steps[i][:simple]
|
|
23
|
+
|
|
24
|
+
case comb
|
|
25
|
+
when :child
|
|
26
|
+
current = current.parent
|
|
27
|
+
return false unless current
|
|
28
|
+
return false unless step_match?(simple, current)
|
|
29
|
+
when :descendant
|
|
30
|
+
current = find_ancestor(current.parent, simple)
|
|
31
|
+
return false unless current
|
|
32
|
+
else
|
|
33
|
+
return false
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
i -= 1
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
true
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def specificity
|
|
43
|
+
ids = 0
|
|
44
|
+
classes = 0
|
|
45
|
+
elements = 0
|
|
46
|
+
|
|
47
|
+
@steps.each do |step|
|
|
48
|
+
simple = step[:simple]
|
|
49
|
+
ids += 1 if simple[:id]
|
|
50
|
+
classes += simple[:classes].length
|
|
51
|
+
elements += 1 if simple[:tag]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
[ids, classes, elements]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def parse_steps(raw)
|
|
60
|
+
tokens = tokenize(raw)
|
|
61
|
+
parts = []
|
|
62
|
+
pending_combinator = nil
|
|
63
|
+
|
|
64
|
+
tokens.each do |tok|
|
|
65
|
+
if tok == ">"
|
|
66
|
+
pending_combinator = :child
|
|
67
|
+
next
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
if tok == " "
|
|
71
|
+
pending_combinator ||= :descendant
|
|
72
|
+
next
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
parts << {simple: parse_simple(tok), combinator: pending_combinator}
|
|
76
|
+
pending_combinator = nil
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Default combinator between left/right parts is descendant.
|
|
80
|
+
# Combinator is stored on the RIGHT step to indicate how it relates to the LEFT.
|
|
81
|
+
parts.each_with_index do |part, idx|
|
|
82
|
+
next if idx == 0
|
|
83
|
+
part[:combinator] ||= :descendant
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
parts
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def tokenize(raw)
|
|
90
|
+
raw = raw.strip
|
|
91
|
+
tokens = []
|
|
92
|
+
buf = +""
|
|
93
|
+
in_space = false
|
|
94
|
+
|
|
95
|
+
raw.each_char do |ch|
|
|
96
|
+
if ch == ">"
|
|
97
|
+
tokens << buf unless buf.empty?
|
|
98
|
+
buf = +""
|
|
99
|
+
tokens << ">"
|
|
100
|
+
in_space = false
|
|
101
|
+
next
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
if ch.match?(/\s/)
|
|
105
|
+
tokens << buf unless buf.empty?
|
|
106
|
+
buf = +""
|
|
107
|
+
tokens << " " unless in_space
|
|
108
|
+
in_space = true
|
|
109
|
+
next
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
in_space = false
|
|
113
|
+
buf << ch
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
tokens << buf unless buf.empty?
|
|
117
|
+
tokens.reject(&:empty?)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def parse_simple(token)
|
|
121
|
+
tag = nil
|
|
122
|
+
id = nil
|
|
123
|
+
classes = []
|
|
124
|
+
|
|
125
|
+
rest = token.to_s
|
|
126
|
+
if rest.match?(/\A[a-zA-Z][a-zA-Z0-9_-]*/)
|
|
127
|
+
m = rest.match(/\A([a-zA-Z][a-zA-Z0-9_-]*)/)
|
|
128
|
+
tag = m[1]
|
|
129
|
+
rest = rest[m[1].length..]
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
rest.scan(/([#.])([a-zA-Z0-9_-]+)/) do |kind, value|
|
|
133
|
+
if kind == "#"
|
|
134
|
+
id = value
|
|
135
|
+
else
|
|
136
|
+
classes << value
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
{tag: tag, id: id, classes: classes}
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def step_match?(simple, node)
|
|
144
|
+
return false if simple[:tag] && node.tag != simple[:tag]
|
|
145
|
+
return false if simple[:id] && node.attributes["id"] != simple[:id]
|
|
146
|
+
|
|
147
|
+
if simple[:classes].any?
|
|
148
|
+
node_classes = node.attributes.fetch("class", "").split
|
|
149
|
+
return false unless simple[:classes].all? { |c| node_classes.include?(c) }
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
true
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def find_ancestor(node, simple)
|
|
156
|
+
current = node
|
|
157
|
+
while current
|
|
158
|
+
return current if step_match?(simple, current)
|
|
159
|
+
current = current.parent
|
|
160
|
+
end
|
|
161
|
+
nil
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SilkLayout
|
|
4
|
+
module HTML
|
|
5
|
+
class Node
|
|
6
|
+
attr_reader :tag, :attributes, :text
|
|
7
|
+
attr_accessor :children, :parent
|
|
8
|
+
attr_accessor :computed_style
|
|
9
|
+
|
|
10
|
+
def initialize(tag:, attributes:, children:, text: nil, parent: nil)
|
|
11
|
+
@tag = tag
|
|
12
|
+
@attributes = attributes
|
|
13
|
+
@children = children
|
|
14
|
+
@text = text
|
|
15
|
+
@parent = parent
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def element?
|
|
19
|
+
!tag.nil?
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.from_nokogiri(node, parent = nil)
|
|
23
|
+
if node.text?
|
|
24
|
+
build_text_node(node, parent)
|
|
25
|
+
else
|
|
26
|
+
build_element_node(node, parent)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.build_text_node(node, parent)
|
|
31
|
+
text = node.text.to_s
|
|
32
|
+
return nil if text.empty?
|
|
33
|
+
|
|
34
|
+
new(
|
|
35
|
+
tag: nil,
|
|
36
|
+
attributes: {},
|
|
37
|
+
children: [],
|
|
38
|
+
text: text,
|
|
39
|
+
parent: parent
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.build_element_node(node, parent)
|
|
44
|
+
element = new(
|
|
45
|
+
tag: node.name,
|
|
46
|
+
attributes: node.attributes.transform_values(&:value),
|
|
47
|
+
children: [],
|
|
48
|
+
parent: parent
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
element.children = node.children.map { |child| from_nokogiri(child, element) }.compact
|
|
52
|
+
element
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|