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.
@@ -7,7 +7,7 @@ module SilkLayout
7
7
  @values = {}
8
8
  @explicit_properties = {}
9
9
 
10
- apply_rules(rules)
10
+ apply_rules(rules, parent_style)
11
11
 
12
12
  apply_inheritance(parent_style)
13
13
  apply_defaults
@@ -39,29 +39,31 @@ module SilkLayout
39
39
 
40
40
  INLINE_SPECIFICITY = [1000, 0, 0].freeze
41
41
 
42
- def apply_rules(rules)
42
+ def apply_rules(rules, parent_style)
43
43
  winners = {}
44
44
 
45
45
  rules.each do |rule|
46
46
  spec = rule.specificity || INLINE_SPECIFICITY
47
47
  order = rule.order || 0
48
48
 
49
- rule.declarations.each do |property, decl|
49
+ each_declaration(rule.declarations).each_with_index do |(property, decl), declaration_index|
50
50
  value = decl.is_a?(Declaration) ? decl.value : decl
51
51
  important = decl.is_a?(Declaration) ? decl.important : false
52
52
 
53
- key = [important ? 1 : 0, spec, order]
54
- current = winners[property]
53
+ Properties.expand_declaration(property, value).each_with_index do |(expanded_property, expanded_value), expansion_index|
54
+ key = [important ? 1 : 0, spec, order, declaration_index, expansion_index]
55
+ current = winners[expanded_property]
55
56
 
56
- if current.nil? || (key <=> current[:key]) == 1
57
- winners[property] = {key: key, value: value}
58
- @explicit_properties[property] = true
57
+ if current.nil? || (key <=> current[:key]) == 1
58
+ winners[expanded_property] = {key: key, value: expanded_value}
59
+ @explicit_properties[expanded_property] = true
60
+ end
59
61
  end
60
62
  end
61
63
  end
62
64
 
63
65
  winners.each do |prop, data|
64
- @values[prop] = data[:value]
66
+ @values[prop] = resolve_css_wide(prop, data[:value], parent_style)
65
67
  end
66
68
  end
67
69
 
@@ -69,15 +71,45 @@ module SilkLayout
69
71
  return unless parent
70
72
 
71
73
  Properties::INHERITED.each do |prop|
72
- @values[prop] ||= parent[prop]
74
+ @values[prop] = parent[prop] unless @values.key?(prop)
73
75
  end
74
76
  end
75
77
 
76
78
  def apply_defaults
77
79
  Properties::DEFAULTS.each do |prop, value|
78
- @values[prop] ||= value
80
+ @values[prop] = value unless @values.key?(prop)
79
81
  end
80
82
  end
83
+
84
+ def each_declaration(declarations)
85
+ return enum_for(:each_declaration, declarations) unless block_given?
86
+
87
+ declarations.each do |property, declaration|
88
+ yield property, declaration
89
+ end
90
+ end
91
+
92
+ def resolve_css_wide(property, value, parent)
93
+ return value unless Properties.css_wide_keyword?(value)
94
+ return value unless Properties.known?(property)
95
+
96
+ case value.to_s.strip.downcase
97
+ when "inherit"
98
+ inherited_value(property, parent)
99
+ when "initial"
100
+ Properties.initial_value(property)
101
+ when "unset"
102
+ if Properties.inherited?(property)
103
+ inherited_value(property, parent)
104
+ else
105
+ Properties.initial_value(property)
106
+ end
107
+ end
108
+ end
109
+
110
+ def inherited_value(property, parent)
111
+ parent&.[](property) || Properties.initial_value(property)
112
+ end
81
113
  end
82
114
  end
83
115
  end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SilkLayout
4
+ module CSS
5
+ class PageRule
6
+ CSS_PX_PER_IN = 96.0
7
+ MM_PER_IN = 25.4
8
+ A4 = [(210.0 / MM_PER_IN) * CSS_PX_PER_IN, (297.0 / MM_PER_IN) * CSS_PX_PER_IN].freeze
9
+ NAMED_SIZES = {
10
+ "a4" => A4
11
+ }.freeze
12
+ ORIENTATIONS = %w[
13
+ landscape
14
+ portrait
15
+ ].freeze
16
+
17
+ attr_reader :selector, :declarations, :order
18
+
19
+ def self.resolve_page_size(page_rules)
20
+ page_rules
21
+ .select { |rule| rule.selector.to_s.empty? }
22
+ .sort_by(&:order)
23
+ .filter_map(&:size)
24
+ .last
25
+ end
26
+
27
+ def self.parse_size(value)
28
+ tokens = value.to_s.strip.split(/\s+/)
29
+ return nil if tokens.empty?
30
+
31
+ orientation = tokens.find { |token| ORIENTATIONS.include?(token.downcase) }&.downcase
32
+ size_tokens = tokens.reject { |token| ORIENTATIONS.include?(token.downcase) }
33
+ dimensions = named_dimensions(size_tokens) || length_dimensions(size_tokens)
34
+ return nil unless dimensions
35
+
36
+ orient(dimensions, orientation)
37
+ end
38
+
39
+ def self.named_dimensions(tokens)
40
+ return nil unless tokens.length == 1
41
+
42
+ named = NAMED_SIZES[tokens[0].downcase]
43
+ named&.dup
44
+ end
45
+
46
+ def self.length_dimensions(tokens)
47
+ return nil unless tokens.length.between?(1, 2)
48
+
49
+ lengths = tokens.map { |token| length_to_px(token) }
50
+ return nil if lengths.any?(&:nil?)
51
+
52
+ [lengths[0], lengths[1] || lengths[0]]
53
+ end
54
+
55
+ def self.length_to_px(token)
56
+ match = token.to_s.match(/\A([-+]?\d*\.?\d+)(px|in)\z/i)
57
+ return nil unless match
58
+
59
+ value = match[1].to_f
60
+ return nil unless value.positive?
61
+
62
+ (match[2].downcase == "in") ? value * CSS_PX_PER_IN : value
63
+ end
64
+
65
+ def self.orient(dimensions, orientation)
66
+ case orientation
67
+ when "landscape"
68
+ (dimensions[0] > dimensions[1]) ? dimensions : dimensions.reverse
69
+ when "portrait"
70
+ (dimensions[0] < dimensions[1]) ? dimensions : dimensions.reverse
71
+ else
72
+ dimensions
73
+ end
74
+ end
75
+
76
+ def initialize(selector:, declarations:, order:)
77
+ @selector = selector
78
+ @declarations = declarations
79
+ @order = order
80
+ end
81
+
82
+ def size
83
+ declaration = last_declaration_named("size")
84
+ return nil unless declaration
85
+
86
+ self.class.parse_size(declaration[1].value)
87
+ end
88
+
89
+ private
90
+
91
+ def last_declaration_named(name)
92
+ index = declarations.length - 1
93
+ while index >= 0
94
+ declaration = declarations[index]
95
+ return declaration if declaration[0] == name
96
+
97
+ index -= 1
98
+ end
99
+
100
+ nil
101
+ end
102
+ end
103
+ end
104
+ end
@@ -5,45 +5,140 @@ require "crass"
5
5
  module SilkLayout
6
6
  module CSS
7
7
  class Parser
8
- def self.parse_all(stylesheets)
9
- rules = []
10
- order = 0
8
+ attr_reader :rules, :page_rules
9
+
10
+ def self.parse_all(stylesheets, media: :print)
11
+ parse_stylesheets(stylesheets, media: media).rules
12
+ end
13
+
14
+ def self.parse_page_rules(stylesheets, media: :print)
15
+ parse_stylesheets(stylesheets, media: media).page_rules
16
+ end
17
+
18
+ def self.parse_stylesheets(stylesheets, media: :print)
19
+ parser = new(media: media)
11
20
 
12
21
  stylesheets.each do |css|
13
- Crass.parse(css).each do |node|
14
- next unless node[:node] == :style_rule
22
+ parser.parse_nodes(Crass.parse(css))
23
+ end
15
24
 
16
- selector_text = node[:selector][:value].strip
17
- selectors = selector_text.split(",").map(&:strip)
25
+ parser
26
+ end
18
27
 
19
- declarations = {}
28
+ def initialize(media:)
29
+ @media = media.to_s.downcase
30
+ @rules = []
31
+ @page_rules = []
32
+ @order = 0
33
+ @page_order = 0
34
+ end
20
35
 
21
- node[:children].each do |child|
22
- next unless child[:node] == :property
36
+ def parse_nodes(nodes)
37
+ nodes.each do |node|
38
+ case node[:node]
39
+ when :style_rule
40
+ append_style_rules(node)
41
+ when :at_rule
42
+ append_at_rule(node)
43
+ end
44
+ end
45
+ end
23
46
 
24
- property = child[:name]
25
- value = child[:value]
47
+ private
26
48
 
27
- declarations[property] = Declaration.new(value: value, important: child[:important] ? true : false)
28
- end
49
+ def append_at_rule(node)
50
+ case node[:name].to_s.downcase
51
+ when "media"
52
+ parse_nodes(Crass.parse(tokens_to_css(node[:block]))) if media_matches?(node[:prelude])
53
+ when "page"
54
+ append_page_rule(node) if print_media?
55
+ end
56
+ end
29
57
 
30
- selectors.each do |raw_selector|
31
- selector = Selector.new(raw_selector)
58
+ def append_style_rules(node)
59
+ selector_text = node[:selector][:value].to_s.strip
60
+ selectors = selector_text.split(",").map(&:strip).reject(&:empty?)
61
+ declarations = declarations_from_children(node[:children])
32
62
 
33
- rules << Rule.new(
34
- selector: selector,
35
- declarations: declarations,
36
- specificity: selector.specificity,
37
- order: order,
38
- origin: :author
39
- )
63
+ selectors.each do |raw_selector|
64
+ selector = Selector.new(raw_selector)
40
65
 
41
- order += 1
42
- end
43
- end
66
+ rules << Rule.new(
67
+ selector: selector,
68
+ declarations: declarations,
69
+ specificity: selector.specificity,
70
+ order: @order,
71
+ origin: :author
72
+ )
73
+
74
+ @order += 1
44
75
  end
76
+ end
77
+
78
+ def append_page_rule(node)
79
+ declarations = declarations_from_block(node[:block])
80
+ return if declarations.empty?
81
+
82
+ page_rules << PageRule.new(
83
+ selector: tokens_to_css(node[:prelude]).strip,
84
+ declarations: declarations,
85
+ order: @page_order
86
+ )
87
+
88
+ @page_order += 1
89
+ end
90
+
91
+ def declarations_from_children(children)
92
+ children.each_with_object([]) do |child, declarations|
93
+ next unless child[:node] == :property
94
+
95
+ declarations << declaration_for(child)
96
+ end
97
+ end
98
+
99
+ def declarations_from_block(block)
100
+ declarations_from_children(Crass.parse_properties(tokens_to_css(block)))
101
+ end
102
+
103
+ def declaration_for(node)
104
+ [
105
+ node[:name].to_s.downcase,
106
+ Declaration.new(value: node[:value].to_s.strip, important: node[:important] ? true : false)
107
+ ]
108
+ end
109
+
110
+ def media_matches?(prelude)
111
+ tokens_to_css(prelude).split(",").any? do |query|
112
+ media_query_matches?(query)
113
+ end
114
+ end
115
+
116
+ def media_query_matches?(query)
117
+ raw = query.to_s.strip.downcase
118
+ return false if raw.empty?
119
+
120
+ raw = raw.sub(/\Aonly\s+/, "")
121
+ negated = raw.start_with?("not ")
122
+ raw = raw.sub(/\Anot\s+/, "") if negated
123
+
124
+ media_type = raw[/\A[a-z][a-z0-9_-]*/]
125
+ matched = media_type.nil? || media_type == "all" || media_type == @media
126
+
127
+ negated ? !matched : matched
128
+ end
129
+
130
+ def print_media?
131
+ @media == "print" || @media == "all"
132
+ end
133
+
134
+ def tokens_to_css(tokens)
135
+ Array(tokens).map { |token| token_to_css(token) }.join
136
+ end
137
+
138
+ def token_to_css(token)
139
+ return token[:tokens].map { |child| token_to_css(child) }.join if token[:tokens]
45
140
 
46
- rules
141
+ token[:raw].to_s
47
142
  end
48
143
  end
49
144
  end
@@ -3,17 +3,315 @@
3
3
  module SilkLayout
4
4
  module CSS
5
5
  module Properties
6
+ EDGES = %w[
7
+ top
8
+ right
9
+ bottom
10
+ left
11
+ ].freeze
12
+
13
+ BORDER_PARTS = %w[
14
+ width
15
+ style
16
+ color
17
+ ].freeze
18
+
19
+ CSS_WIDE_KEYWORDS = %w[
20
+ inherit
21
+ initial
22
+ unset
23
+ ].freeze
24
+
6
25
  INHERITED = %w[
7
26
  color
8
27
  font-size
9
28
  font-family
29
+ font-weight
30
+ font-style
31
+ line-height
10
32
  ].freeze
11
33
 
12
- DEFAULTS = {
34
+ SPACING_INITIALS = %w[
35
+ margin
36
+ padding
37
+ ].flat_map do |property|
38
+ EDGES.map { |side| ["#{property}-#{side}", "0"] }
39
+ end.to_h.freeze
40
+
41
+ BORDER_INITIALS = EDGES.flat_map do |side|
42
+ [
43
+ ["border-#{side}-width", "medium"],
44
+ ["border-#{side}-style", "none"],
45
+ ["border-#{side}-color", "black"]
46
+ ]
47
+ end.to_h.freeze
48
+
49
+ INITIAL_VALUES = {
13
50
  "color" => "black",
14
51
  "font-size" => "16px",
15
- "display" => "inline"
16
- }.freeze
52
+ "font-family" => "Helvetica",
53
+ "font-weight" => "normal",
54
+ "font-style" => "normal",
55
+ "line-height" => "normal",
56
+ "display" => "inline",
57
+ "width" => "auto",
58
+ "height" => "auto",
59
+ "box-sizing" => "content-box",
60
+ "min-width" => "0",
61
+ "max-width" => "none",
62
+ "background-color" => "transparent",
63
+ "flex-direction" => "row",
64
+ "flex-wrap" => "nowrap",
65
+ "justify-content" => "flex-start",
66
+ "align-items" => "stretch",
67
+ "flex-grow" => "0",
68
+ "flex-shrink" => "1",
69
+ "flex-basis" => "auto",
70
+ "row-gap" => "0",
71
+ "column-gap" => "0"
72
+ }.merge(SPACING_INITIALS).merge(BORDER_INITIALS).freeze
73
+
74
+ DEFAULTS = INITIAL_VALUES
75
+ KNOWN = INITIAL_VALUES.keys.freeze
76
+
77
+ def self.expand_declaration(property, value)
78
+ property = property.to_s.downcase
79
+ value = value.to_s.strip
80
+ expanded = [[property, value]]
81
+
82
+ expanded.concat(
83
+ if css_wide_keyword?(value)
84
+ css_wide_expansion(property, value)
85
+ else
86
+ shorthand_expansion(property, value)
87
+ end
88
+ )
89
+ end
90
+
91
+ def self.css_wide_keyword?(value)
92
+ CSS_WIDE_KEYWORDS.include?(value.to_s.strip.downcase)
93
+ end
94
+
95
+ def self.initial_value(property)
96
+ INITIAL_VALUES[property]
97
+ end
98
+
99
+ def self.known?(property)
100
+ KNOWN.include?(property)
101
+ end
102
+
103
+ def self.inherited?(property)
104
+ INHERITED.include?(property)
105
+ end
106
+
107
+ def self.css_wide_expansion(property, value)
108
+ case property
109
+ when "margin", "padding"
110
+ EDGES.map { |side| ["#{property}-#{side}", value] }
111
+ when "border"
112
+ all_border_longhands(value)
113
+ when "border-width", "border-style", "border-color"
114
+ border_edge_longhands(property.delete_prefix("border-"), value)
115
+ when /\Aborder-(top|right|bottom|left)\z/
116
+ border_side_longhands(Regexp.last_match(1), value)
117
+ when "background"
118
+ [["background-color", value]]
119
+ when "flex"
120
+ flex_longhands(value, value, value)
121
+ when "flex-flow"
122
+ flex_flow_longhands(value, value)
123
+ when "gap"
124
+ gap_longhands(value, value)
125
+ else
126
+ []
127
+ end
128
+ end
129
+
130
+ def self.shorthand_expansion(property, value)
131
+ case property
132
+ when "margin", "padding"
133
+ edge_values(value).map { |side, side_value| ["#{property}-#{side}", side_value] }
134
+ when "border"
135
+ parsed = border_shorthand(value)
136
+ all_border_longhands do |side, part|
137
+ parsed[part] || INITIAL_VALUES.fetch("border-#{side}-#{part}")
138
+ end
139
+ when "border-width", "border-style", "border-color"
140
+ part = property.delete_prefix("border-")
141
+ edge_values(value).map { |side, side_value| ["border-#{side}-#{part}", side_value] }
142
+ when /\Aborder-(top|right|bottom|left)\z/
143
+ side = Regexp.last_match(1)
144
+ parsed = border_shorthand(value)
145
+ border_side_longhands(side) do |part|
146
+ parsed[part] || INITIAL_VALUES.fetch("border-#{side}-#{part}")
147
+ end
148
+ when "background"
149
+ [["background-color", background_color(value)]]
150
+ when "flex"
151
+ flex_shorthand(value).map { |part, part_value| ["flex-#{part}", part_value] }
152
+ when "flex-flow"
153
+ flex_flow(value).map { |part, part_value| ["flex-#{part}", part_value] }
154
+ when "gap"
155
+ gap_values(value)
156
+ else
157
+ []
158
+ end
159
+ end
160
+
161
+ def self.edge_values(value)
162
+ tokens = split_tokens(value)
163
+ values =
164
+ case tokens.length
165
+ when 0
166
+ []
167
+ when 1
168
+ [tokens[0], tokens[0], tokens[0], tokens[0]]
169
+ when 2
170
+ [tokens[0], tokens[1], tokens[0], tokens[1]]
171
+ when 3
172
+ [tokens[0], tokens[1], tokens[2], tokens[1]]
173
+ else
174
+ [tokens[0], tokens[1], tokens[2], tokens[3]]
175
+ end
176
+
177
+ EDGES.zip(values).to_h
178
+ end
179
+
180
+ def self.all_border_longhands(value = nil)
181
+ EDGES.flat_map do |side|
182
+ BORDER_PARTS.map do |part|
183
+ ["border-#{side}-#{part}", block_given? ? yield(side, part) : value]
184
+ end
185
+ end
186
+ end
187
+
188
+ def self.border_edge_longhands(part, value)
189
+ EDGES.map { |side| ["border-#{side}-#{part}", value] }
190
+ end
191
+
192
+ def self.border_side_longhands(side, value = nil)
193
+ BORDER_PARTS.map do |part|
194
+ ["border-#{side}-#{part}", block_given? ? yield(part) : value]
195
+ end
196
+ end
197
+
198
+ def self.border_shorthand(value)
199
+ split_tokens(value).each_with_object({}) do |token, parsed|
200
+ if border_width?(token)
201
+ parsed["width"] ||= token
202
+ elsif border_style?(token)
203
+ parsed["style"] ||= token
204
+ else
205
+ parsed["color"] ||= token
206
+ end
207
+ end
208
+ end
209
+
210
+ def self.background_color(value)
211
+ split_tokens(value).find { |token| color_token?(token) } || INITIAL_VALUES.fetch("background-color")
212
+ end
213
+
214
+ def self.flex_shorthand(value)
215
+ tokens = split_tokens(value)
216
+
217
+ case tokens.join(" ")
218
+ when "none"
219
+ {"grow" => "0", "shrink" => "0", "basis" => "auto"}
220
+ when "auto"
221
+ {"grow" => "1", "shrink" => "1", "basis" => "auto"}
222
+ else
223
+ numbers = tokens.select { |token| numeric?(token) }
224
+ basis = tokens.find { |token| !numeric?(token) }
225
+
226
+ {
227
+ "grow" => numbers[0] || "1",
228
+ "shrink" => numbers[1] || "1",
229
+ "basis" => basis || (numbers.any? ? "0px" : "auto")
230
+ }
231
+ end
232
+ end
233
+
234
+ def self.flex_longhands(grow, shrink, basis)
235
+ [
236
+ ["flex-grow", grow],
237
+ ["flex-shrink", shrink],
238
+ ["flex-basis", basis]
239
+ ]
240
+ end
241
+
242
+ def self.flex_flow(value)
243
+ parsed = split_tokens(value).each_with_object({}) do |token, acc|
244
+ if %w[row row-reverse column column-reverse].include?(token)
245
+ acc["direction"] = token
246
+ elsif %w[nowrap wrap wrap-reverse].include?(token)
247
+ acc["wrap"] = token
248
+ end
249
+ end
250
+
251
+ {
252
+ "direction" => parsed["direction"] || INITIAL_VALUES.fetch("flex-direction"),
253
+ "wrap" => parsed["wrap"] || INITIAL_VALUES.fetch("flex-wrap")
254
+ }
255
+ end
256
+
257
+ def self.flex_flow_longhands(direction, wrap)
258
+ [
259
+ ["flex-direction", direction],
260
+ ["flex-wrap", wrap]
261
+ ]
262
+ end
263
+
264
+ def self.gap_values(value)
265
+ values = split_tokens(value)
266
+ row = values[0] || INITIAL_VALUES.fetch("row-gap")
267
+ column = values[1] || row
268
+
269
+ gap_longhands(row, column)
270
+ end
271
+
272
+ def self.gap_longhands(row, column)
273
+ [
274
+ ["row-gap", row],
275
+ ["column-gap", column]
276
+ ]
277
+ end
278
+
279
+ def self.split_tokens(value)
280
+ Values.split_tokens(value)
281
+ end
282
+
283
+ def self.numeric?(value)
284
+ value.to_s.match?(/\A[-+]?\d*\.?\d+\z/)
285
+ end
286
+
287
+ def self.border_width?(value)
288
+ value.to_s.match?(/\A[-+]?\d*\.?\d+(?:px)?\z/) || %w[thin medium thick].include?(value.to_s)
289
+ end
290
+
291
+ def self.border_style?(value)
292
+ %w[
293
+ none
294
+ hidden
295
+ dotted
296
+ dashed
297
+ solid
298
+ double
299
+ groove
300
+ ridge
301
+ inset
302
+ outset
303
+ ].include?(value.to_s)
304
+ end
305
+
306
+ def self.color_token?(value)
307
+ raw = value.to_s.strip
308
+ return false if raw.empty?
309
+ return true if raw.start_with?("#")
310
+ return true if SilkLayout::CSS::Color.parse(raw)
311
+ return false if %w[none transparent inherit initial unset].include?(raw)
312
+
313
+ !border_width?(raw) && !border_style?(raw) && !raw.include?("(") && !raw.include?("/")
314
+ end
17
315
  end
18
316
  end
19
317
  end