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
|
@@ -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,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
|