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 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,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SilkLayout
4
+ module CSS
5
+ Declaration = Struct.new(:value, :important)
6
+
7
+ Rule = Struct.new(
8
+ :selector,
9
+ :declarations,
10
+ :specificity,
11
+ :order,
12
+ :origin
13
+ )
14
+ end
15
+ 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