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.
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "nokogiri"
5
+ require "uri"
6
+
7
+ module SilkLayout
8
+ module HTML
9
+ class Parser
10
+ def self.parse_document(html, url: nil)
11
+ document = Nokogiri::HTML(html)
12
+
13
+ base_uri = base_uri_for(document, url)
14
+ stylesheets = extract_stylesheets!(document, base_uri)
15
+
16
+ [Node.from_nokogiri(document.root), stylesheets]
17
+ end
18
+
19
+ def self.extract_stylesheets!(document, base_uri)
20
+ stylesheets = []
21
+ cache = {}
22
+
23
+ document.css("style, link").each do |node|
24
+ if node.name == "style"
25
+ stylesheets << node.content.to_s
26
+ node.remove
27
+ next
28
+ end
29
+
30
+ next unless node.name == "link"
31
+
32
+ rel = node["rel"].to_s.downcase.split
33
+ next unless rel.include?("stylesheet")
34
+
35
+ href = node["href"].to_s.strip
36
+ next if href.empty?
37
+
38
+ stylesheets << fetch_stylesheet(href, base_uri, cache)
39
+ node.remove
40
+ end
41
+
42
+ stylesheets
43
+ end
44
+
45
+ def self.base_uri_for(document, url)
46
+ doc_uri = normalize_document_url(url)
47
+
48
+ base_href = document.at_css("base[href]")&.[]("href")
49
+ return doc_uri unless base_href
50
+
51
+ normalize_href(base_href.to_s, doc_uri)
52
+ end
53
+
54
+ def self.normalize_document_url(url)
55
+ return default_workdir_uri unless url
56
+
57
+ uri = URI.parse(url.to_s)
58
+ return uri if uri.scheme
59
+
60
+ path = Pathname.new(url.to_s).expand_path
61
+ file_uri_for(path)
62
+ rescue URI::InvalidURIError
63
+ path = Pathname.new(url.to_s).expand_path
64
+ file_uri_for(path)
65
+ end
66
+
67
+ def self.default_workdir_uri
68
+ file_uri_for(Pathname.pwd)
69
+ end
70
+
71
+ def self.file_uri_for(path)
72
+ p = path
73
+ p = p.dirname if p.file?
74
+
75
+ dir = p.to_s
76
+ dir = "#{dir}/" unless dir.end_with?("/")
77
+
78
+ URI::Generic.build(scheme: "file", path: dir)
79
+ end
80
+
81
+ def self.fetch_stylesheet(href, base_uri, cache)
82
+ resolved = normalize_href(href, base_uri)
83
+ key = resolved.to_s
84
+ return cache[key] if cache.key?(key)
85
+
86
+ css =
87
+ case resolved.scheme
88
+ when "file", nil
89
+ path = uri_unescape(resolved.path)
90
+ raise "Stylesheet not found: #{path}" unless File.exist?(path)
91
+
92
+ File.read(path)
93
+ when "http", "https"
94
+ fetch_http(resolved)
95
+ else
96
+ raise "Unsupported stylesheet scheme: #{resolved.scheme} (#{resolved})"
97
+ end
98
+
99
+ css = inline_css_imports(css, resolved, cache)
100
+ cache[key] = css
101
+ end
102
+
103
+ def self.normalize_href(href, base_uri)
104
+ href = href.to_s
105
+ uri = URI.parse(href)
106
+ return uri if uri.scheme
107
+
108
+ URI.join(base_uri.to_s, href)
109
+ rescue URI::InvalidURIError
110
+ URI.join(base_uri.to_s, URI::DEFAULT_PARSER.escape(href))
111
+ end
112
+
113
+ def self.fetch_http(uri)
114
+ current = uri
115
+ redirects = 0
116
+
117
+ loop do
118
+ http = Net::HTTP.new(current.host, current.port)
119
+ http.use_ssl = (current.scheme == "https")
120
+ http.open_timeout = 10
121
+ http.read_timeout = 10
122
+
123
+ request = Net::HTTP::Get.new(current)
124
+ request["User-Agent"] = "SilkLayout/#{SilkLayout::VERSION}"
125
+
126
+ response = http.request(request)
127
+
128
+ case response
129
+ when Net::HTTPSuccess
130
+ return response.body.to_s
131
+ when Net::HTTPRedirection
132
+ location = response["location"].to_s
133
+ raise "Missing redirect location for #{current}" if location.empty?
134
+
135
+ redirects += 1
136
+ raise "Too many redirects fetching #{uri}" if redirects > 5
137
+
138
+ current = URI.join(current.to_s, location)
139
+ else
140
+ raise "Failed to fetch #{current} (HTTP #{response.code})"
141
+ end
142
+ end
143
+ end
144
+
145
+ def self.inline_css_imports(css, stylesheet_uri, cache)
146
+ return css unless css.include?("@import")
147
+
148
+ base = stylesheet_base_uri(stylesheet_uri)
149
+ remaining = css.dup
150
+ inlined = +""
151
+ seen = {}
152
+
153
+ 20.times do
154
+ m = remaining.match(/@import\s+(?:url\()?
155
+ \s*['"]?([^'")\s;]+)['"]?
156
+ \s*\)?\s*;/ix)
157
+ break unless m
158
+
159
+ href = m[1]
160
+ break if seen[href]
161
+
162
+ seen[href] = true
163
+ import_uri = normalize_href(href, base)
164
+ imported = fetch_stylesheet(import_uri.to_s, base, cache)
165
+
166
+ inlined << imported.to_s << "\n"
167
+ remaining.sub!(m[0], "")
168
+ end
169
+
170
+ (inlined + remaining)
171
+ end
172
+
173
+ def self.stylesheet_base_uri(stylesheet_uri)
174
+ if stylesheet_uri.scheme == "file"
175
+ file_uri_for(Pathname.new(uri_unescape(stylesheet_uri.path)))
176
+ else
177
+ uri = stylesheet_uri.dup
178
+ uri.path = uri.path.to_s.sub(/[^\/]+\z/, "")
179
+ uri
180
+ end
181
+ end
182
+
183
+ def self.uri_unescape(value)
184
+ URI::RFC2396_PARSER.unescape(value.to_s)
185
+ end
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SilkLayout
4
+ module Layout
5
+ class BlockLayout
6
+ def self.layout(box, context, cursor_y = 0, parent_x = 0, containing_width = nil)
7
+ return FlexLayout.layout(box, context, cursor_y, parent_x, containing_width) if box.is_a?(FlexBox)
8
+
9
+ box.x = parent_x + box.margin[:left]
10
+ box.y = cursor_y + box.margin[:top]
11
+
12
+ content_x =
13
+ box.x + box.border[:left] + box.padding[:left]
14
+
15
+ content_y =
16
+ box.y + box.border[:top] + box.padding[:top]
17
+
18
+ available_width = containing_width || context.width
19
+ if box.explicit_width
20
+ content_width = box.width
21
+ else
22
+ content_width =
23
+ available_width -
24
+ box.margin[:left] - box.margin[:right] -
25
+ box.border[:left] - box.border[:right] -
26
+ box.padding[:left] - box.padding[:right]
27
+ content_width = 0 if content_width < 0
28
+ end
29
+
30
+ current_y = content_y
31
+ new_children = []
32
+ inline_buffer = []
33
+
34
+ box.children.each do |child|
35
+ if child.is_a?(InlineBox)
36
+ inline_buffer << child
37
+ next
38
+ end
39
+
40
+ if inline_buffer.any?
41
+ lines = InlineFormatter.layout(inline_buffer, content_width, content_x, current_y)
42
+ lines.each do |line|
43
+ line.x = content_x
44
+ line.y = current_y
45
+ current_y += line.height
46
+ new_children << line
47
+ end
48
+ inline_buffer.clear
49
+ end
50
+
51
+ layout(child, context, current_y, content_x, content_width)
52
+
53
+ current_y +=
54
+ child.height +
55
+ child.margin[:top] +
56
+ child.margin[:bottom]
57
+
58
+ new_children << child
59
+ end
60
+
61
+ if inline_buffer.any?
62
+ lines = InlineFormatter.layout(inline_buffer, content_width, content_x, current_y)
63
+ lines.each do |line|
64
+ line.x = content_x
65
+ line.y = current_y
66
+ current_y += line.height
67
+ new_children << line
68
+ end
69
+ end
70
+
71
+ box.children = new_children
72
+
73
+ content_height = current_y - content_y
74
+ content_height = [content_height, box.height].max if box.explicit_height
75
+
76
+ max_child_width =
77
+ box.children.map(&:width).max || 0
78
+
79
+ content_width = max_child_width if !box.explicit_width && content_width == 0
80
+
81
+ box.width =
82
+ content_width +
83
+ box.padding[:left] + box.padding[:right] +
84
+ box.border[:left] + box.border[:right]
85
+
86
+ box.height =
87
+ content_height +
88
+ box.padding[:top] + box.padding[:bottom] +
89
+ box.border[:top] + box.border[:bottom]
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SilkLayout
4
+ module Layout
5
+ class Box
6
+ attr_accessor :x,
7
+ :y,
8
+ :width,
9
+ :height,
10
+ :children,
11
+ :node,
12
+ :margin,
13
+ :padding,
14
+ :border,
15
+ :border_color,
16
+ :background_color,
17
+ :explicit_width,
18
+ :explicit_height,
19
+ :flex,
20
+ :display,
21
+ :has_border
22
+
23
+ def initialize(node)
24
+ @node = node
25
+ @children = []
26
+
27
+ @x = 0
28
+ @y = 0
29
+ @width = 0
30
+ @height = 0
31
+
32
+ @margin = {top: 0, right: 0, bottom: 0, left: 0}
33
+ @padding = {top: 0, right: 0, bottom: 0, left: 0}
34
+ @border = {top: 0, right: 0, bottom: 0, left: 0}
35
+ @border_color = {
36
+ top: nil,
37
+ right: nil,
38
+ bottom: nil,
39
+ left: nil
40
+ }
41
+ @background_color = nil
42
+
43
+ @explicit_width = false
44
+ @explicit_height = false
45
+ @flex = {}
46
+ @display = nil
47
+ end
48
+
49
+ def add_child(box)
50
+ @children << box
51
+ end
52
+
53
+ def border_box_x
54
+ x
55
+ end
56
+
57
+ def border_box_y
58
+ y
59
+ end
60
+
61
+ def border_box_width
62
+ width
63
+ end
64
+
65
+ def border_box_height
66
+ height
67
+ end
68
+ end
69
+
70
+ class BlockBox < Box; end
71
+ class FlexBox < BlockBox; end
72
+ class InlineBox < Box; end
73
+
74
+ class AnonymousBlockBox < Box
75
+ def initialize
76
+ super(nil)
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SilkLayout
4
+ module Layout
5
+ class BoxBuilder
6
+ def self.build(dom_root)
7
+ FormattingBuilder.build(dom_root)
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SilkLayout
4
+ module Layout
5
+ class Context
6
+ attr_reader :width
7
+
8
+ def initialize(width:)
9
+ @width = width
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SilkLayout
4
+ module Layout
5
+ class Engine
6
+ DEFAULT_VIEWPORT_WIDTH = 800
7
+
8
+ def self.layout(dom, css_rules, viewport_width: DEFAULT_VIEWPORT_WIDTH)
9
+ CSS::Cascade.apply(dom, css_rules)
10
+
11
+ box_tree = FormattingBuilder.build(dom)
12
+
13
+ root = Root.find(box_tree)
14
+
15
+ context = Context.new(width: viewport_width)
16
+ BlockLayout.layout(root, context)
17
+
18
+ root
19
+ end
20
+ end
21
+ end
22
+ end