cataract 0.1.3 → 0.1.4
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 +4 -4
- data/.github/workflows/ci-manual-rubies.yml +27 -0
- data/.overcommit.yml +1 -1
- data/.rubocop.yml +62 -0
- data/.rubocop_todo.yml +186 -0
- data/BENCHMARKS.md +60 -139
- data/CHANGELOG.md +10 -0
- data/README.md +30 -2
- data/Rakefile +49 -22
- data/cataract.gemspec +4 -1
- data/ext/cataract/cataract.c +47 -47
- data/ext/cataract/css_parser.c +17 -33
- data/ext/cataract/merge.c +6 -0
- data/lib/cataract/at_rule.rb +8 -9
- data/lib/cataract/declaration.rb +18 -0
- data/lib/cataract/import_resolver.rb +3 -4
- data/lib/cataract/pure/byte_constants.rb +69 -0
- data/lib/cataract/pure/helpers.rb +35 -0
- data/lib/cataract/pure/imports.rb +255 -0
- data/lib/cataract/pure/merge.rb +1146 -0
- data/lib/cataract/pure/parser.rb +1236 -0
- data/lib/cataract/pure/serializer.rb +590 -0
- data/lib/cataract/pure/specificity.rb +206 -0
- data/lib/cataract/pure.rb +130 -0
- data/lib/cataract/rule.rb +22 -13
- data/lib/cataract/stylesheet.rb +14 -9
- data/lib/cataract/version.rb +1 -1
- data/lib/cataract.rb +18 -5
- metadata +12 -25
- data/benchmarks/benchmark_harness.rb +0 -193
- data/benchmarks/benchmark_merging.rb +0 -121
- data/benchmarks/benchmark_optimization_comparison.rb +0 -168
- data/benchmarks/benchmark_parsing.rb +0 -153
- data/benchmarks/benchmark_ragel_removal.rb +0 -56
- data/benchmarks/benchmark_runner.rb +0 -70
- data/benchmarks/benchmark_serialization.rb +0 -180
- data/benchmarks/benchmark_shorthand.rb +0 -109
- data/benchmarks/benchmark_shorthand_expansion.rb +0 -176
- data/benchmarks/benchmark_specificity.rb +0 -124
- data/benchmarks/benchmark_string_allocation.rb +0 -151
- data/benchmarks/benchmark_stylesheet_to_s.rb +0 -62
- data/benchmarks/benchmark_to_s_cached.rb +0 -55
- data/benchmarks/benchmark_value_splitter.rb +0 -54
- data/benchmarks/benchmark_yjit.rb +0 -158
- data/benchmarks/benchmark_yjit_workers.rb +0 -61
- data/benchmarks/profile_to_s.rb +0 -23
- data/benchmarks/speedup_calculator.rb +0 -83
- data/benchmarks/system_metadata.rb +0 -81
- data/benchmarks/templates/benchmarks.md.erb +0 -221
- data/benchmarks/yjit_tests.rb +0 -141
- data/scripts/fuzzer/run.rb +0 -828
- data/scripts/fuzzer/worker.rb +0 -99
- data/scripts/generate_benchmarks_md.rb +0 -155
|
@@ -0,0 +1,1146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Pure Ruby CSS merge implementation
|
|
4
|
+
# NO REGEXP ALLOWED - use string manipulation only
|
|
5
|
+
|
|
6
|
+
module Cataract
|
|
7
|
+
module Merge
|
|
8
|
+
# Property name constants (US-ASCII for merge output)
|
|
9
|
+
PROP_MARGIN = 'margin'.encode(Encoding::US_ASCII).freeze
|
|
10
|
+
PROP_MARGIN_TOP = 'margin-top'.encode(Encoding::US_ASCII).freeze
|
|
11
|
+
PROP_MARGIN_RIGHT = 'margin-right'.encode(Encoding::US_ASCII).freeze
|
|
12
|
+
PROP_MARGIN_BOTTOM = 'margin-bottom'.encode(Encoding::US_ASCII).freeze
|
|
13
|
+
PROP_MARGIN_LEFT = 'margin-left'.encode(Encoding::US_ASCII).freeze
|
|
14
|
+
|
|
15
|
+
PROP_PADDING = 'padding'.encode(Encoding::US_ASCII).freeze
|
|
16
|
+
PROP_PADDING_TOP = 'padding-top'.encode(Encoding::US_ASCII).freeze
|
|
17
|
+
PROP_PADDING_RIGHT = 'padding-right'.encode(Encoding::US_ASCII).freeze
|
|
18
|
+
PROP_PADDING_BOTTOM = 'padding-bottom'.encode(Encoding::US_ASCII).freeze
|
|
19
|
+
PROP_PADDING_LEFT = 'padding-left'.encode(Encoding::US_ASCII).freeze
|
|
20
|
+
|
|
21
|
+
PROP_BORDER = 'border'.encode(Encoding::US_ASCII).freeze
|
|
22
|
+
PROP_BORDER_WIDTH = 'border-width'.encode(Encoding::US_ASCII).freeze
|
|
23
|
+
PROP_BORDER_STYLE = 'border-style'.encode(Encoding::US_ASCII).freeze
|
|
24
|
+
PROP_BORDER_COLOR = 'border-color'.encode(Encoding::US_ASCII).freeze
|
|
25
|
+
|
|
26
|
+
PROP_BORDER_TOP = 'border-top'.encode(Encoding::US_ASCII).freeze
|
|
27
|
+
PROP_BORDER_RIGHT = 'border-right'.encode(Encoding::US_ASCII).freeze
|
|
28
|
+
PROP_BORDER_BOTTOM = 'border-bottom'.encode(Encoding::US_ASCII).freeze
|
|
29
|
+
PROP_BORDER_LEFT = 'border-left'.encode(Encoding::US_ASCII).freeze
|
|
30
|
+
|
|
31
|
+
PROP_BORDER_TOP_WIDTH = 'border-top-width'.encode(Encoding::US_ASCII).freeze
|
|
32
|
+
PROP_BORDER_RIGHT_WIDTH = 'border-right-width'.encode(Encoding::US_ASCII).freeze
|
|
33
|
+
PROP_BORDER_BOTTOM_WIDTH = 'border-bottom-width'.encode(Encoding::US_ASCII).freeze
|
|
34
|
+
PROP_BORDER_LEFT_WIDTH = 'border-left-width'.encode(Encoding::US_ASCII).freeze
|
|
35
|
+
|
|
36
|
+
PROP_BORDER_TOP_STYLE = 'border-top-style'.encode(Encoding::US_ASCII).freeze
|
|
37
|
+
PROP_BORDER_RIGHT_STYLE = 'border-right-style'.encode(Encoding::US_ASCII).freeze
|
|
38
|
+
PROP_BORDER_BOTTOM_STYLE = 'border-bottom-style'.encode(Encoding::US_ASCII).freeze
|
|
39
|
+
PROP_BORDER_LEFT_STYLE = 'border-left-style'.encode(Encoding::US_ASCII).freeze
|
|
40
|
+
|
|
41
|
+
PROP_BORDER_TOP_COLOR = 'border-top-color'.encode(Encoding::US_ASCII).freeze
|
|
42
|
+
PROP_BORDER_RIGHT_COLOR = 'border-right-color'.encode(Encoding::US_ASCII).freeze
|
|
43
|
+
PROP_BORDER_BOTTOM_COLOR = 'border-bottom-color'.encode(Encoding::US_ASCII).freeze
|
|
44
|
+
PROP_BORDER_LEFT_COLOR = 'border-left-color'.encode(Encoding::US_ASCII).freeze
|
|
45
|
+
|
|
46
|
+
PROP_FONT = 'font'.encode(Encoding::US_ASCII).freeze
|
|
47
|
+
PROP_FONT_STYLE = 'font-style'.encode(Encoding::US_ASCII).freeze
|
|
48
|
+
PROP_FONT_VARIANT = 'font-variant'.encode(Encoding::US_ASCII).freeze
|
|
49
|
+
PROP_FONT_WEIGHT = 'font-weight'.encode(Encoding::US_ASCII).freeze
|
|
50
|
+
PROP_FONT_SIZE = 'font-size'.encode(Encoding::US_ASCII).freeze
|
|
51
|
+
PROP_LINE_HEIGHT = 'line-height'.encode(Encoding::US_ASCII).freeze
|
|
52
|
+
PROP_FONT_FAMILY = 'font-family'.encode(Encoding::US_ASCII).freeze
|
|
53
|
+
|
|
54
|
+
PROP_BACKGROUND = 'background'.encode(Encoding::US_ASCII).freeze
|
|
55
|
+
PROP_BACKGROUND_COLOR = 'background-color'.encode(Encoding::US_ASCII).freeze
|
|
56
|
+
PROP_BACKGROUND_IMAGE = 'background-image'.encode(Encoding::US_ASCII).freeze
|
|
57
|
+
PROP_BACKGROUND_REPEAT = 'background-repeat'.encode(Encoding::US_ASCII).freeze
|
|
58
|
+
PROP_BACKGROUND_ATTACHMENT = 'background-attachment'.encode(Encoding::US_ASCII).freeze
|
|
59
|
+
PROP_BACKGROUND_POSITION = 'background-position'.encode(Encoding::US_ASCII).freeze
|
|
60
|
+
|
|
61
|
+
PROP_LIST_STYLE = 'list-style'.encode(Encoding::US_ASCII).freeze
|
|
62
|
+
PROP_LIST_STYLE_TYPE = 'list-style-type'.encode(Encoding::US_ASCII).freeze
|
|
63
|
+
PROP_LIST_STYLE_POSITION = 'list-style-position'.encode(Encoding::US_ASCII).freeze
|
|
64
|
+
PROP_LIST_STYLE_IMAGE = 'list-style-image'.encode(Encoding::US_ASCII).freeze
|
|
65
|
+
|
|
66
|
+
# Shorthand property families
|
|
67
|
+
MARGIN_SIDES = [PROP_MARGIN_TOP, PROP_MARGIN_RIGHT, PROP_MARGIN_BOTTOM, PROP_MARGIN_LEFT].freeze
|
|
68
|
+
PADDING_SIDES = [PROP_PADDING_TOP, PROP_PADDING_RIGHT, PROP_PADDING_BOTTOM, PROP_PADDING_LEFT].freeze
|
|
69
|
+
|
|
70
|
+
BORDER_WIDTHS = [
|
|
71
|
+
PROP_BORDER_TOP_WIDTH,
|
|
72
|
+
PROP_BORDER_RIGHT_WIDTH,
|
|
73
|
+
PROP_BORDER_BOTTOM_WIDTH,
|
|
74
|
+
PROP_BORDER_LEFT_WIDTH
|
|
75
|
+
].freeze
|
|
76
|
+
|
|
77
|
+
BORDER_STYLES = [
|
|
78
|
+
PROP_BORDER_TOP_STYLE,
|
|
79
|
+
PROP_BORDER_RIGHT_STYLE,
|
|
80
|
+
PROP_BORDER_BOTTOM_STYLE,
|
|
81
|
+
PROP_BORDER_LEFT_STYLE
|
|
82
|
+
].freeze
|
|
83
|
+
|
|
84
|
+
BORDER_COLORS = [
|
|
85
|
+
PROP_BORDER_TOP_COLOR,
|
|
86
|
+
PROP_BORDER_RIGHT_COLOR,
|
|
87
|
+
PROP_BORDER_BOTTOM_COLOR,
|
|
88
|
+
PROP_BORDER_LEFT_COLOR
|
|
89
|
+
].freeze
|
|
90
|
+
|
|
91
|
+
# Side name constants (for string operations, not CSS properties)
|
|
92
|
+
SIDE_TOP = 'top'
|
|
93
|
+
SIDE_RIGHT = 'right'
|
|
94
|
+
SIDE_BOTTOM = 'bottom'
|
|
95
|
+
SIDE_LEFT = 'left'
|
|
96
|
+
BORDER_SIDES = [SIDE_TOP, SIDE_RIGHT, SIDE_BOTTOM, SIDE_LEFT].freeze
|
|
97
|
+
|
|
98
|
+
FONT_PROPERTIES = [
|
|
99
|
+
PROP_FONT_STYLE,
|
|
100
|
+
PROP_FONT_VARIANT,
|
|
101
|
+
PROP_FONT_WEIGHT,
|
|
102
|
+
PROP_FONT_SIZE,
|
|
103
|
+
PROP_LINE_HEIGHT,
|
|
104
|
+
PROP_FONT_FAMILY
|
|
105
|
+
].freeze
|
|
106
|
+
|
|
107
|
+
BACKGROUND_PROPERTIES = [
|
|
108
|
+
PROP_BACKGROUND_COLOR,
|
|
109
|
+
PROP_BACKGROUND_IMAGE,
|
|
110
|
+
PROP_BACKGROUND_REPEAT,
|
|
111
|
+
PROP_BACKGROUND_POSITION,
|
|
112
|
+
PROP_BACKGROUND_ATTACHMENT
|
|
113
|
+
].freeze
|
|
114
|
+
|
|
115
|
+
LIST_STYLE_PROPERTIES = [PROP_LIST_STYLE_TYPE, PROP_LIST_STYLE_POSITION, PROP_LIST_STYLE_IMAGE].freeze
|
|
116
|
+
BORDER_ALL = (BORDER_WIDTHS + BORDER_STYLES + BORDER_COLORS).freeze
|
|
117
|
+
|
|
118
|
+
# List style keywords
|
|
119
|
+
LIST_STYLE_POSITION_KEYWORDS = %w[inside outside].freeze
|
|
120
|
+
|
|
121
|
+
# Border property keywords
|
|
122
|
+
BORDER_WIDTH_KEYWORDS = %w[thin medium thick].freeze
|
|
123
|
+
BORDER_STYLE_KEYWORDS = %w[none hidden dotted dashed solid double groove ridge inset outset].freeze
|
|
124
|
+
|
|
125
|
+
# Font property keywords
|
|
126
|
+
FONT_SIZE_KEYWORDS = %w[xx-small x-small small medium large x-large xx-large smaller larger].freeze
|
|
127
|
+
FONT_STYLE_KEYWORDS = %w[normal italic oblique].freeze
|
|
128
|
+
FONT_VARIANT_KEYWORDS = %w[normal small-caps].freeze
|
|
129
|
+
FONT_WEIGHT_KEYWORDS = %w[normal bold bolder lighter 100 200 300 400 500 600 700 800 900].freeze
|
|
130
|
+
|
|
131
|
+
# Background property keywords
|
|
132
|
+
BACKGROUND_REPEAT_KEYWORDS = %w[repeat repeat-x repeat-y no-repeat space round].freeze
|
|
133
|
+
BACKGROUND_ATTACHMENT_KEYWORDS = %w[scroll fixed local].freeze
|
|
134
|
+
BACKGROUND_POSITION_KEYWORDS = %w[left right center top bottom].freeze
|
|
135
|
+
|
|
136
|
+
# Merge stylesheet according to CSS cascade rules
|
|
137
|
+
#
|
|
138
|
+
# @param stylesheet [Stylesheet] Stylesheet to merge
|
|
139
|
+
# @param mutate [Boolean] If true, mutate the stylesheet; otherwise create new one
|
|
140
|
+
# @return [Stylesheet] Merged stylesheet
|
|
141
|
+
def self.merge(stylesheet, mutate: false)
|
|
142
|
+
# Separate AtRules (pass-through) from regular Rules (to merge)
|
|
143
|
+
at_rules = []
|
|
144
|
+
regular_rules = []
|
|
145
|
+
|
|
146
|
+
stylesheet.rules.each do |rule|
|
|
147
|
+
if rule.at_rule?
|
|
148
|
+
at_rules << rule
|
|
149
|
+
else
|
|
150
|
+
regular_rules << rule
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Expand shorthands in regular rules only (AtRules don't have declarations)
|
|
155
|
+
regular_rules.each { |rule| expand_shorthands!(rule) }
|
|
156
|
+
|
|
157
|
+
merged_rules = []
|
|
158
|
+
|
|
159
|
+
# Always group by selector and preserve original selectors
|
|
160
|
+
# (Nesting is flattened during parsing, so we just merge by resolved selector)
|
|
161
|
+
grouped = regular_rules.group_by(&:selector)
|
|
162
|
+
grouped.each do |selector, rules|
|
|
163
|
+
merged_rule = merge_rules_for_selector(selector, rules)
|
|
164
|
+
merged_rules << merged_rule if merged_rule
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Recreate shorthands where possible
|
|
168
|
+
merged_rules.each { |rule| recreate_shorthands!(rule) }
|
|
169
|
+
|
|
170
|
+
# Add passthrough AtRules to output
|
|
171
|
+
merged_rules.concat(at_rules)
|
|
172
|
+
|
|
173
|
+
# Create result stylesheet
|
|
174
|
+
if mutate
|
|
175
|
+
stylesheet.instance_variable_set(:@rules, merged_rules)
|
|
176
|
+
# Update rule IDs
|
|
177
|
+
merged_rules.each_with_index { |rule, i| rule.id = i }
|
|
178
|
+
# Clear media index (no media rules after merge flattens everything)
|
|
179
|
+
stylesheet.instance_variable_set(:@media_index, {})
|
|
180
|
+
stylesheet
|
|
181
|
+
else
|
|
182
|
+
# Create new Stylesheet with merged rules
|
|
183
|
+
result = Stylesheet.new
|
|
184
|
+
result.instance_variable_set(:@rules, merged_rules)
|
|
185
|
+
result.instance_variable_set(:@media_index, {})
|
|
186
|
+
result.instance_variable_set(:@charset, stylesheet.charset)
|
|
187
|
+
# Update rule IDs
|
|
188
|
+
merged_rules.each_with_index { |rule, i| rule.id = i }
|
|
189
|
+
result
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Merge multiple rules with same selector
|
|
194
|
+
#
|
|
195
|
+
# @param selector [String] The selector
|
|
196
|
+
# @param rules [Array<Rule>] Rules with this selector
|
|
197
|
+
# @return [Rule] Merged rule with cascaded declarations
|
|
198
|
+
def self.merge_rules_for_selector(selector, rules)
|
|
199
|
+
# Build declaration map: property => [source_order, specificity, important, value]
|
|
200
|
+
decl_map = {}
|
|
201
|
+
|
|
202
|
+
rules.each do |rule|
|
|
203
|
+
spec = rule.specificity || calculate_specificity(rule.selector)
|
|
204
|
+
|
|
205
|
+
rule.declarations.each_with_index do |decl, idx|
|
|
206
|
+
# Property is already US-ASCII and lowercase from parser
|
|
207
|
+
prop = decl.property
|
|
208
|
+
|
|
209
|
+
# Calculate source order (higher = later)
|
|
210
|
+
source_order = rule.id * 1000 + idx
|
|
211
|
+
|
|
212
|
+
existing = decl_map[prop]
|
|
213
|
+
|
|
214
|
+
# Apply cascade rules:
|
|
215
|
+
# 1. !important always wins over non-important
|
|
216
|
+
# 2. Higher specificity wins
|
|
217
|
+
# 3. Later source order wins (if specificity and importance are equal)
|
|
218
|
+
|
|
219
|
+
if existing.nil?
|
|
220
|
+
decl_map[prop] = [source_order, spec, decl.important, decl.value]
|
|
221
|
+
else
|
|
222
|
+
existing_order, existing_spec, existing_important, _existing_val = existing
|
|
223
|
+
|
|
224
|
+
# Determine winner
|
|
225
|
+
should_replace = false
|
|
226
|
+
|
|
227
|
+
if decl.important && !existing_important
|
|
228
|
+
# New is important, existing is not -> new wins
|
|
229
|
+
should_replace = true
|
|
230
|
+
elsif !decl.important && existing_important
|
|
231
|
+
# Existing is important, new is not -> existing wins
|
|
232
|
+
should_replace = false
|
|
233
|
+
elsif spec > existing_spec
|
|
234
|
+
# Higher specificity wins
|
|
235
|
+
should_replace = true
|
|
236
|
+
elsif spec < existing_spec
|
|
237
|
+
# Lower specificity loses
|
|
238
|
+
should_replace = false
|
|
239
|
+
else
|
|
240
|
+
# Same specificity and importance -> later source order wins
|
|
241
|
+
should_replace = source_order > existing_order
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
if should_replace
|
|
245
|
+
decl_map[prop] = [source_order, spec, decl.important, decl.value]
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Build final declarations array
|
|
252
|
+
# NOTE: Using each with << instead of map for performance (1.05-1.11x faster)
|
|
253
|
+
# The << pattern is faster than map's implicit array return (even without YJIT)
|
|
254
|
+
#
|
|
255
|
+
# NOTE: We don't sort by source_order here because:
|
|
256
|
+
# 1. Hash iteration order in Ruby is insertion order (since Ruby 1.9)
|
|
257
|
+
# 2. Declaration order doesn't affect CSS behavior (cascade is already resolved)
|
|
258
|
+
# 3. Sorting would add overhead for purely aesthetic output
|
|
259
|
+
# The output order is roughly source order but may vary when properties are
|
|
260
|
+
# overridden by later rules with higher specificity or importance.
|
|
261
|
+
declarations = []
|
|
262
|
+
decl_map.each do |prop, (_order, _spec, important, value)|
|
|
263
|
+
declarations << Declaration.new(prop, value, important)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
return nil if declarations.empty?
|
|
267
|
+
|
|
268
|
+
# Create merged rule
|
|
269
|
+
Rule.new(
|
|
270
|
+
0, # ID will be updated later
|
|
271
|
+
selector,
|
|
272
|
+
declarations,
|
|
273
|
+
rules.first.specificity, # Use first rule's specificity
|
|
274
|
+
nil, # No parent after flattening
|
|
275
|
+
nil # No nesting style after flattening
|
|
276
|
+
)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Calculate specificity for a selector
|
|
280
|
+
#
|
|
281
|
+
# @param selector [String] CSS selector
|
|
282
|
+
# @return [Integer] Specificity value
|
|
283
|
+
def self.calculate_specificity(selector)
|
|
284
|
+
Cataract.calculate_specificity(selector)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Expand shorthand properties in a rule (mutates declarations)
|
|
288
|
+
#
|
|
289
|
+
# @param rule [Rule] Rule to expand
|
|
290
|
+
def self.expand_shorthands!(rule)
|
|
291
|
+
expanded = []
|
|
292
|
+
|
|
293
|
+
rule.declarations.each do |decl|
|
|
294
|
+
prop = decl.property
|
|
295
|
+
|
|
296
|
+
case prop
|
|
297
|
+
when 'margin'
|
|
298
|
+
expanded.concat(expand_margin(decl))
|
|
299
|
+
when 'padding'
|
|
300
|
+
expanded.concat(expand_padding(decl))
|
|
301
|
+
when 'border'
|
|
302
|
+
expanded.concat(expand_border(decl))
|
|
303
|
+
when 'border-top', 'border-right', 'border-bottom', 'border-left'
|
|
304
|
+
expanded.concat(expand_border_side(decl))
|
|
305
|
+
when 'border-width'
|
|
306
|
+
expanded.concat(expand_border_width(decl))
|
|
307
|
+
when 'border-style'
|
|
308
|
+
expanded.concat(expand_border_style(decl))
|
|
309
|
+
when 'border-color'
|
|
310
|
+
expanded.concat(expand_border_color(decl))
|
|
311
|
+
when 'font'
|
|
312
|
+
expanded.concat(expand_font(decl))
|
|
313
|
+
when 'background'
|
|
314
|
+
expanded.concat(expand_background(decl))
|
|
315
|
+
when 'list-style'
|
|
316
|
+
expanded.concat(expand_list_style(decl))
|
|
317
|
+
else
|
|
318
|
+
expanded << decl
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
rule.declarations.replace(expanded)
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# Expand margin shorthand
|
|
326
|
+
def self.expand_margin(decl)
|
|
327
|
+
sides = parse_four_sides(decl.value)
|
|
328
|
+
[
|
|
329
|
+
Declaration.new(PROP_MARGIN_TOP, sides[0], decl.important),
|
|
330
|
+
Declaration.new(PROP_MARGIN_RIGHT, sides[1], decl.important),
|
|
331
|
+
Declaration.new(PROP_MARGIN_BOTTOM, sides[2], decl.important),
|
|
332
|
+
Declaration.new(PROP_MARGIN_LEFT, sides[3], decl.important)
|
|
333
|
+
]
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# Expand padding shorthand
|
|
337
|
+
def self.expand_padding(decl)
|
|
338
|
+
sides = parse_four_sides(decl.value)
|
|
339
|
+
[
|
|
340
|
+
Declaration.new(PROP_PADDING_TOP, sides[0], decl.important),
|
|
341
|
+
Declaration.new(PROP_PADDING_RIGHT, sides[1], decl.important),
|
|
342
|
+
Declaration.new(PROP_PADDING_BOTTOM, sides[2], decl.important),
|
|
343
|
+
Declaration.new(PROP_PADDING_LEFT, sides[3], decl.important)
|
|
344
|
+
]
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# Parse four-sided value (margin/padding)
|
|
348
|
+
# "10px" -> ["10px", "10px", "10px", "10px"]
|
|
349
|
+
# "10px 20px" -> ["10px", "20px", "10px", "20px"]
|
|
350
|
+
# "10px 20px 30px" -> ["10px", "20px", "30px", "20px"]
|
|
351
|
+
# "10px 20px 30px 40px" -> ["10px", "20px", "30px", "40px"]
|
|
352
|
+
def self.parse_four_sides(value)
|
|
353
|
+
parts = split_on_whitespace(value)
|
|
354
|
+
|
|
355
|
+
case parts.length
|
|
356
|
+
when 1
|
|
357
|
+
[parts[0], parts[0], parts[0], parts[0]]
|
|
358
|
+
when 2
|
|
359
|
+
[parts[0], parts[1], parts[0], parts[1]]
|
|
360
|
+
when 3
|
|
361
|
+
[parts[0], parts[1], parts[2], parts[1]]
|
|
362
|
+
else
|
|
363
|
+
[parts[0], parts[1], parts[2], parts[3]]
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# Split value on whitespace (handling calc() and other functions)
|
|
368
|
+
def self.split_on_whitespace(value)
|
|
369
|
+
parts = []
|
|
370
|
+
current = String.new
|
|
371
|
+
paren_depth = 0
|
|
372
|
+
|
|
373
|
+
i = 0
|
|
374
|
+
len = value.bytesize
|
|
375
|
+
while i < len
|
|
376
|
+
byte = value.getbyte(i)
|
|
377
|
+
|
|
378
|
+
if byte == BYTE_LPAREN
|
|
379
|
+
paren_depth += 1
|
|
380
|
+
current << byte
|
|
381
|
+
elsif byte == BYTE_RPAREN
|
|
382
|
+
paren_depth -= 1
|
|
383
|
+
current << byte
|
|
384
|
+
elsif byte == BYTE_SPACE && paren_depth == 0
|
|
385
|
+
parts << current unless current.empty?
|
|
386
|
+
current = String.new
|
|
387
|
+
else
|
|
388
|
+
current << byte
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
i += 1
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
parts << current unless current.empty?
|
|
395
|
+
parts
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# Expand border shorthand (e.g., "1px solid red")
|
|
399
|
+
def self.expand_border(decl)
|
|
400
|
+
# Parse border value
|
|
401
|
+
width, style, color = parse_border_value(decl.value)
|
|
402
|
+
|
|
403
|
+
result = []
|
|
404
|
+
|
|
405
|
+
# Expand to all sides using property constants
|
|
406
|
+
if width
|
|
407
|
+
result << Declaration.new(PROP_BORDER_TOP_WIDTH, width, decl.important)
|
|
408
|
+
result << Declaration.new(PROP_BORDER_RIGHT_WIDTH, width, decl.important)
|
|
409
|
+
result << Declaration.new(PROP_BORDER_BOTTOM_WIDTH, width, decl.important)
|
|
410
|
+
result << Declaration.new(PROP_BORDER_LEFT_WIDTH, width, decl.important)
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
if style
|
|
414
|
+
result << Declaration.new(PROP_BORDER_TOP_STYLE, style, decl.important)
|
|
415
|
+
result << Declaration.new(PROP_BORDER_RIGHT_STYLE, style, decl.important)
|
|
416
|
+
result << Declaration.new(PROP_BORDER_BOTTOM_STYLE, style, decl.important)
|
|
417
|
+
result << Declaration.new(PROP_BORDER_LEFT_STYLE, style, decl.important)
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
if color
|
|
421
|
+
result << Declaration.new(PROP_BORDER_TOP_COLOR, color, decl.important)
|
|
422
|
+
result << Declaration.new(PROP_BORDER_RIGHT_COLOR, color, decl.important)
|
|
423
|
+
result << Declaration.new(PROP_BORDER_BOTTOM_COLOR, color, decl.important)
|
|
424
|
+
result << Declaration.new(PROP_BORDER_LEFT_COLOR, color, decl.important)
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
result
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
# Expand border-side shorthand (e.g., "border-top: 1px solid red")
|
|
431
|
+
def self.expand_border_side(decl)
|
|
432
|
+
# Extract side from property name (e.g., "border-top" -> "top")
|
|
433
|
+
side = decl.property.byteslice(7..-1) # Skip "border-" prefix
|
|
434
|
+
width, style, color = parse_border_value(decl.value)
|
|
435
|
+
|
|
436
|
+
result = []
|
|
437
|
+
|
|
438
|
+
# Map side to property constants
|
|
439
|
+
if width
|
|
440
|
+
width_prop = case side
|
|
441
|
+
when SIDE_TOP then PROP_BORDER_TOP_WIDTH
|
|
442
|
+
when SIDE_RIGHT then PROP_BORDER_RIGHT_WIDTH
|
|
443
|
+
when SIDE_BOTTOM then PROP_BORDER_BOTTOM_WIDTH
|
|
444
|
+
when SIDE_LEFT then PROP_BORDER_LEFT_WIDTH
|
|
445
|
+
end
|
|
446
|
+
result << Declaration.new(width_prop, width, decl.important)
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
if style
|
|
450
|
+
style_prop = case side
|
|
451
|
+
when SIDE_TOP then PROP_BORDER_TOP_STYLE
|
|
452
|
+
when SIDE_RIGHT then PROP_BORDER_RIGHT_STYLE
|
|
453
|
+
when SIDE_BOTTOM then PROP_BORDER_BOTTOM_STYLE
|
|
454
|
+
when SIDE_LEFT then PROP_BORDER_LEFT_STYLE
|
|
455
|
+
end
|
|
456
|
+
result << Declaration.new(style_prop, style, decl.important)
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
if color
|
|
460
|
+
color_prop = case side
|
|
461
|
+
when SIDE_TOP then PROP_BORDER_TOP_COLOR
|
|
462
|
+
when SIDE_RIGHT then PROP_BORDER_RIGHT_COLOR
|
|
463
|
+
when SIDE_BOTTOM then PROP_BORDER_BOTTOM_COLOR
|
|
464
|
+
when SIDE_LEFT then PROP_BORDER_LEFT_COLOR
|
|
465
|
+
end
|
|
466
|
+
result << Declaration.new(color_prop, color, decl.important)
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
result
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
# Expand border-width shorthand
|
|
473
|
+
def self.expand_border_width(decl)
|
|
474
|
+
sides = parse_four_sides(decl.value)
|
|
475
|
+
[
|
|
476
|
+
Declaration.new(PROP_BORDER_TOP_WIDTH, sides[0], decl.important),
|
|
477
|
+
Declaration.new(PROP_BORDER_RIGHT_WIDTH, sides[1], decl.important),
|
|
478
|
+
Declaration.new(PROP_BORDER_BOTTOM_WIDTH, sides[2], decl.important),
|
|
479
|
+
Declaration.new(PROP_BORDER_LEFT_WIDTH, sides[3], decl.important)
|
|
480
|
+
]
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
# Expand border-style shorthand
|
|
484
|
+
def self.expand_border_style(decl)
|
|
485
|
+
sides = parse_four_sides(decl.value)
|
|
486
|
+
[
|
|
487
|
+
Declaration.new(PROP_BORDER_TOP_STYLE, sides[0], decl.important),
|
|
488
|
+
Declaration.new(PROP_BORDER_RIGHT_STYLE, sides[1], decl.important),
|
|
489
|
+
Declaration.new(PROP_BORDER_BOTTOM_STYLE, sides[2], decl.important),
|
|
490
|
+
Declaration.new(PROP_BORDER_LEFT_STYLE, sides[3], decl.important)
|
|
491
|
+
]
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
# Expand border-color shorthand
|
|
495
|
+
def self.expand_border_color(decl)
|
|
496
|
+
sides = parse_four_sides(decl.value)
|
|
497
|
+
[
|
|
498
|
+
Declaration.new(PROP_BORDER_TOP_COLOR, sides[0], decl.important),
|
|
499
|
+
Declaration.new(PROP_BORDER_RIGHT_COLOR, sides[1], decl.important),
|
|
500
|
+
Declaration.new(PROP_BORDER_BOTTOM_COLOR, sides[2], decl.important),
|
|
501
|
+
Declaration.new(PROP_BORDER_LEFT_COLOR, sides[3], decl.important)
|
|
502
|
+
]
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
# Parse border value (e.g., "1px solid red" -> ["1px", "solid", "red"])
|
|
506
|
+
def self.parse_border_value(value)
|
|
507
|
+
parts = split_on_whitespace(value)
|
|
508
|
+
width = nil
|
|
509
|
+
style = nil
|
|
510
|
+
color = nil
|
|
511
|
+
|
|
512
|
+
# Identify each part by type
|
|
513
|
+
parts.each do |part|
|
|
514
|
+
if is_border_width?(part)
|
|
515
|
+
width = part
|
|
516
|
+
elsif is_border_style?(part)
|
|
517
|
+
style = part
|
|
518
|
+
else
|
|
519
|
+
color = part # Assume color if not width or style
|
|
520
|
+
end
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
[width, style, color]
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
# Check if value looks like a border width
|
|
527
|
+
def self.is_border_width?(value)
|
|
528
|
+
# Check for numeric values or width keywords
|
|
529
|
+
return true if BORDER_WIDTH_KEYWORDS.include?(value)
|
|
530
|
+
|
|
531
|
+
# Check if value contains a digit (byte-by-byte)
|
|
532
|
+
i = 0
|
|
533
|
+
len = value.bytesize
|
|
534
|
+
while i < len
|
|
535
|
+
byte = value.getbyte(i)
|
|
536
|
+
return true if byte >= BYTE_DIGIT_0 && byte <= BYTE_DIGIT_9
|
|
537
|
+
|
|
538
|
+
i += 1
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
false
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
# Check if value is a border style
|
|
545
|
+
def self.is_border_style?(value)
|
|
546
|
+
BORDER_STYLE_KEYWORDS.include?(value)
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
# Expand font shorthand
|
|
550
|
+
# Format: [style] [variant] [weight] size[/line-height] family
|
|
551
|
+
def self.expand_font(decl)
|
|
552
|
+
value = decl.value
|
|
553
|
+
parts = split_on_whitespace(value)
|
|
554
|
+
|
|
555
|
+
# Need at least 2 parts (size and family)
|
|
556
|
+
return [decl] if parts.length < 2
|
|
557
|
+
|
|
558
|
+
# Parse from left to right
|
|
559
|
+
# Optional: style, variant, weight (can appear in any order)
|
|
560
|
+
# Required: size (with optional /line-height), family
|
|
561
|
+
|
|
562
|
+
i = 0
|
|
563
|
+
style = nil
|
|
564
|
+
variant = nil
|
|
565
|
+
weight = nil
|
|
566
|
+
size = nil
|
|
567
|
+
line_height = nil
|
|
568
|
+
family_parts = []
|
|
569
|
+
|
|
570
|
+
# Process optional style/variant/weight
|
|
571
|
+
while i < parts.length - 1 # Leave at least 1 for family
|
|
572
|
+
part = parts[i]
|
|
573
|
+
|
|
574
|
+
# Check if this could be size (has digit or is a size keyword)
|
|
575
|
+
if is_font_size?(part)
|
|
576
|
+
# This is the size, rest is family
|
|
577
|
+
size_part = part
|
|
578
|
+
|
|
579
|
+
# Check for line-height (find '/' byte)
|
|
580
|
+
slash_idx = nil
|
|
581
|
+
j = 0
|
|
582
|
+
len = size_part.bytesize
|
|
583
|
+
while j < len
|
|
584
|
+
if size_part.getbyte(j) == BYTE_SLASH_FWD
|
|
585
|
+
slash_idx = j
|
|
586
|
+
break
|
|
587
|
+
end
|
|
588
|
+
j += 1
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
if slash_idx
|
|
592
|
+
size = size_part.byteslice(0, slash_idx)
|
|
593
|
+
line_height = size_part.byteslice((slash_idx + 1)..-1)
|
|
594
|
+
else
|
|
595
|
+
size = size_part
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
# Rest is family
|
|
599
|
+
family_parts = parts[(i + 1)..-1]
|
|
600
|
+
break
|
|
601
|
+
elsif is_font_style?(part)
|
|
602
|
+
style = part
|
|
603
|
+
elsif is_font_variant?(part)
|
|
604
|
+
variant = part
|
|
605
|
+
elsif is_font_weight?(part)
|
|
606
|
+
weight = part
|
|
607
|
+
else
|
|
608
|
+
# Unknown, might be start of family - treat everything from here as family
|
|
609
|
+
family_parts = parts[i..-1]
|
|
610
|
+
break
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
i += 1
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
family = family_parts.join(' ')
|
|
617
|
+
|
|
618
|
+
# Font shorthand sets ALL longhand properties
|
|
619
|
+
# Unspecified values get CSS initial values
|
|
620
|
+
# Size and family are required; if missing, return unexpanded
|
|
621
|
+
return [decl] if !size || family.empty?
|
|
622
|
+
|
|
623
|
+
result = []
|
|
624
|
+
result << Declaration.new(PROP_FONT_STYLE, style || 'normal', decl.important)
|
|
625
|
+
result << Declaration.new(PROP_FONT_VARIANT, variant || 'normal', decl.important)
|
|
626
|
+
result << Declaration.new(PROP_FONT_WEIGHT, weight || 'normal', decl.important)
|
|
627
|
+
result << Declaration.new(PROP_FONT_SIZE, size, decl.important)
|
|
628
|
+
result << Declaration.new(PROP_LINE_HEIGHT, line_height || 'normal', decl.important)
|
|
629
|
+
result << Declaration.new(PROP_FONT_FAMILY, family, decl.important)
|
|
630
|
+
|
|
631
|
+
result
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
# Check if value is a font size
|
|
635
|
+
def self.is_font_size?(value)
|
|
636
|
+
# Has digit or is a keyword
|
|
637
|
+
i = 0
|
|
638
|
+
len = value.bytesize
|
|
639
|
+
while i < len
|
|
640
|
+
byte = value.getbyte(i)
|
|
641
|
+
return true if byte >= BYTE_DIGIT_0 && byte <= BYTE_DIGIT_9
|
|
642
|
+
|
|
643
|
+
i += 1
|
|
644
|
+
end
|
|
645
|
+
FONT_SIZE_KEYWORDS.include?(value)
|
|
646
|
+
end
|
|
647
|
+
|
|
648
|
+
# Check if value is a font style
|
|
649
|
+
def self.is_font_style?(value)
|
|
650
|
+
FONT_STYLE_KEYWORDS.include?(value)
|
|
651
|
+
end
|
|
652
|
+
|
|
653
|
+
# Check if value is a font variant
|
|
654
|
+
def self.is_font_variant?(value)
|
|
655
|
+
FONT_VARIANT_KEYWORDS.include?(value)
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
# Check if value is a font weight
|
|
659
|
+
def self.is_font_weight?(value)
|
|
660
|
+
# Check for numeric weights like 400, 700
|
|
661
|
+
i = 0
|
|
662
|
+
len = value.bytesize
|
|
663
|
+
while i < len
|
|
664
|
+
byte = value.getbyte(i)
|
|
665
|
+
return true if byte >= BYTE_DIGIT_0 && byte <= BYTE_DIGIT_9
|
|
666
|
+
|
|
667
|
+
i += 1
|
|
668
|
+
end
|
|
669
|
+
FONT_WEIGHT_KEYWORDS.include?(value)
|
|
670
|
+
end
|
|
671
|
+
|
|
672
|
+
# Expand background shorthand
|
|
673
|
+
# Format: [color] [image] [repeat] [attachment] [position]
|
|
674
|
+
def self.expand_background(decl)
|
|
675
|
+
value = decl.value
|
|
676
|
+
parts = split_on_whitespace(value)
|
|
677
|
+
|
|
678
|
+
return [decl] if parts.empty?
|
|
679
|
+
|
|
680
|
+
# Parse background components (simple heuristic)
|
|
681
|
+
# According to CSS spec, background shorthand sets ALL properties
|
|
682
|
+
# Any unspecified values get their initial values
|
|
683
|
+
color = nil
|
|
684
|
+
image = nil
|
|
685
|
+
repeat = nil
|
|
686
|
+
attachment = nil
|
|
687
|
+
position = nil
|
|
688
|
+
|
|
689
|
+
parts.each do |part|
|
|
690
|
+
if starts_with_url?(part) || part == 'none'
|
|
691
|
+
image = part
|
|
692
|
+
elsif BACKGROUND_REPEAT_KEYWORDS.include?(part)
|
|
693
|
+
repeat = part
|
|
694
|
+
elsif BACKGROUND_ATTACHMENT_KEYWORDS.include?(part)
|
|
695
|
+
attachment = part
|
|
696
|
+
elsif is_position_value?(part)
|
|
697
|
+
position ||= String.new
|
|
698
|
+
position << ' ' unless position.empty?
|
|
699
|
+
position << part
|
|
700
|
+
else
|
|
701
|
+
# Assume it's a color
|
|
702
|
+
color = part
|
|
703
|
+
end
|
|
704
|
+
end
|
|
705
|
+
|
|
706
|
+
# Background shorthand sets ALL longhand properties
|
|
707
|
+
# Unspecified values get CSS initial values
|
|
708
|
+
result = []
|
|
709
|
+
result << Declaration.new(PROP_BACKGROUND_COLOR, color || 'transparent', decl.important)
|
|
710
|
+
result << Declaration.new(PROP_BACKGROUND_IMAGE, image || 'none', decl.important)
|
|
711
|
+
result << Declaration.new(PROP_BACKGROUND_REPEAT, repeat || 'repeat', decl.important)
|
|
712
|
+
result << Declaration.new(PROP_BACKGROUND_ATTACHMENT, attachment || 'scroll', decl.important)
|
|
713
|
+
result << Declaration.new(PROP_BACKGROUND_POSITION, position || '0% 0%', decl.important)
|
|
714
|
+
|
|
715
|
+
result
|
|
716
|
+
end
|
|
717
|
+
|
|
718
|
+
# Check if value starts with 'url('
|
|
719
|
+
def self.starts_with_url?(value)
|
|
720
|
+
return false if value.bytesize < 4
|
|
721
|
+
|
|
722
|
+
value.getbyte(0) == BYTE_LOWER_U &&
|
|
723
|
+
value.getbyte(1) == BYTE_LOWER_R &&
|
|
724
|
+
value.getbyte(2) == BYTE_LOWER_L &&
|
|
725
|
+
value.getbyte(3) == BYTE_LPAREN
|
|
726
|
+
end
|
|
727
|
+
|
|
728
|
+
# Check if value is a position value (for background-position)
|
|
729
|
+
def self.is_position_value?(value)
|
|
730
|
+
return true if BACKGROUND_POSITION_KEYWORDS.include?(value)
|
|
731
|
+
|
|
732
|
+
# Check for '%' or digits
|
|
733
|
+
i = 0
|
|
734
|
+
len = value.bytesize
|
|
735
|
+
while i < len
|
|
736
|
+
byte = value.getbyte(i)
|
|
737
|
+
return true if byte == BYTE_PERCENT
|
|
738
|
+
return true if byte >= BYTE_DIGIT_0 && byte <= BYTE_DIGIT_9
|
|
739
|
+
|
|
740
|
+
i += 1
|
|
741
|
+
end
|
|
742
|
+
false
|
|
743
|
+
end
|
|
744
|
+
|
|
745
|
+
# Expand list-style shorthand
|
|
746
|
+
# Format: [type] [position] [image]
|
|
747
|
+
def self.expand_list_style(decl)
|
|
748
|
+
value = decl.value
|
|
749
|
+
parts = split_on_whitespace(value)
|
|
750
|
+
|
|
751
|
+
return [decl] if parts.empty?
|
|
752
|
+
|
|
753
|
+
result = []
|
|
754
|
+
type = nil
|
|
755
|
+
position = nil
|
|
756
|
+
image = nil
|
|
757
|
+
|
|
758
|
+
parts.each do |part|
|
|
759
|
+
if starts_with_url?(part) || part == 'none'
|
|
760
|
+
image = part
|
|
761
|
+
elsif LIST_STYLE_POSITION_KEYWORDS.include?(part)
|
|
762
|
+
position = part
|
|
763
|
+
else
|
|
764
|
+
# Assume it's a type (disc, circle, square, etc.)
|
|
765
|
+
type = part
|
|
766
|
+
end
|
|
767
|
+
end
|
|
768
|
+
|
|
769
|
+
result << Declaration.new(PROP_LIST_STYLE_TYPE, type, decl.important) if type
|
|
770
|
+
result << Declaration.new(PROP_LIST_STYLE_POSITION, position, decl.important) if position
|
|
771
|
+
result << Declaration.new(PROP_LIST_STYLE_IMAGE, image, decl.important) if image
|
|
772
|
+
|
|
773
|
+
result.empty? ? [decl] : result
|
|
774
|
+
end
|
|
775
|
+
|
|
776
|
+
# Recreate shorthand properties where possible (mutates declarations)
|
|
777
|
+
#
|
|
778
|
+
# @param rule [Rule] Rule to recreate shorthands in
|
|
779
|
+
def self.recreate_shorthands!(rule)
|
|
780
|
+
# Build property map
|
|
781
|
+
prop_map = {}
|
|
782
|
+
rule.declarations.each { |d| prop_map[d.property] = d }
|
|
783
|
+
|
|
784
|
+
# Try to recreate margin
|
|
785
|
+
recreate_margin!(rule, prop_map)
|
|
786
|
+
|
|
787
|
+
# Try to recreate padding
|
|
788
|
+
recreate_padding!(rule, prop_map)
|
|
789
|
+
|
|
790
|
+
# Try to recreate border
|
|
791
|
+
recreate_border!(rule, prop_map)
|
|
792
|
+
|
|
793
|
+
# Try to recreate list-style
|
|
794
|
+
recreate_list_style!(rule, prop_map)
|
|
795
|
+
|
|
796
|
+
# Try to recreate font
|
|
797
|
+
recreate_font!(rule, prop_map)
|
|
798
|
+
|
|
799
|
+
# Try to recreate background
|
|
800
|
+
recreate_background!(rule, prop_map)
|
|
801
|
+
end
|
|
802
|
+
|
|
803
|
+
# Try to recreate margin shorthand
|
|
804
|
+
def self.recreate_margin!(rule, prop_map)
|
|
805
|
+
# Use each + << instead of map (1.05-1.20x faster, called once per rule during merge)
|
|
806
|
+
sides = []
|
|
807
|
+
MARGIN_SIDES.each { |s| sides << prop_map[s] }
|
|
808
|
+
return unless sides.all? # Need all four sides
|
|
809
|
+
|
|
810
|
+
# Check if all have same importance
|
|
811
|
+
# Use each + << instead of map
|
|
812
|
+
importances = []
|
|
813
|
+
sides.each { |s| importances << s.important }
|
|
814
|
+
importances.uniq!
|
|
815
|
+
return if importances.length > 1 # Mixed importance, can't create shorthand
|
|
816
|
+
|
|
817
|
+
# Use each + << instead of map
|
|
818
|
+
values = []
|
|
819
|
+
sides.each { |s| values << s.value }
|
|
820
|
+
important = sides.first.important
|
|
821
|
+
|
|
822
|
+
# Create optimized shorthand
|
|
823
|
+
shorthand_value = optimize_four_sides(values)
|
|
824
|
+
|
|
825
|
+
# Remove individual sides and append shorthand
|
|
826
|
+
# Note: We append rather than insert at original position to match C implementation behavior
|
|
827
|
+
rule.declarations.reject! { |d| MARGIN_SIDES.include?(d.property) }
|
|
828
|
+
rule.declarations << Declaration.new(PROP_MARGIN, shorthand_value, important)
|
|
829
|
+
end
|
|
830
|
+
|
|
831
|
+
# Try to recreate padding shorthand
|
|
832
|
+
def self.recreate_padding!(rule, prop_map)
|
|
833
|
+
# Use each + << instead of map (1.05-1.20x faster, called once per rule during merge)
|
|
834
|
+
sides = []
|
|
835
|
+
PADDING_SIDES.each { |s| sides << prop_map[s] }
|
|
836
|
+
return unless sides.all?
|
|
837
|
+
|
|
838
|
+
# Use each + << instead of map
|
|
839
|
+
importances = []
|
|
840
|
+
sides.each { |s| importances << s.important }
|
|
841
|
+
importances.uniq!
|
|
842
|
+
return if importances.length > 1
|
|
843
|
+
|
|
844
|
+
# Use each + << instead of map
|
|
845
|
+
values = []
|
|
846
|
+
sides.each { |s| values << s.value }
|
|
847
|
+
important = sides.first.important
|
|
848
|
+
|
|
849
|
+
shorthand_value = optimize_four_sides(values)
|
|
850
|
+
|
|
851
|
+
# Remove individual sides and append shorthand
|
|
852
|
+
# Note: We append rather than insert at original position to match C implementation behavior
|
|
853
|
+
rule.declarations.reject! { |d| PADDING_SIDES.include?(d.property) }
|
|
854
|
+
rule.declarations << Declaration.new(PROP_PADDING, shorthand_value, important)
|
|
855
|
+
end
|
|
856
|
+
|
|
857
|
+
# Helper: Check if all declarations have same value and importance
|
|
858
|
+
# Does single pass instead of multiple .map calls
|
|
859
|
+
def self.check_all_same?(decls)
|
|
860
|
+
return false if decls.empty?
|
|
861
|
+
|
|
862
|
+
first_val = decls[0].value
|
|
863
|
+
first_imp = decls[0].important
|
|
864
|
+
|
|
865
|
+
i = 1
|
|
866
|
+
while i < decls.length
|
|
867
|
+
return false if decls[i].value != first_val
|
|
868
|
+
return false if decls[i].important != first_imp
|
|
869
|
+
|
|
870
|
+
i += 1
|
|
871
|
+
end
|
|
872
|
+
|
|
873
|
+
true
|
|
874
|
+
end
|
|
875
|
+
|
|
876
|
+
# Try to recreate border shorthand
|
|
877
|
+
def self.recreate_border!(rule, prop_map)
|
|
878
|
+
# Check if we have all width/style/color properties with same values for all sides
|
|
879
|
+
# Use each + << instead of map (1.05-1.20x faster, called once per rule during merge)
|
|
880
|
+
widths = []
|
|
881
|
+
BORDER_WIDTHS.each { |p| widths << prop_map[p] }
|
|
882
|
+
styles = []
|
|
883
|
+
BORDER_STYLES.each { |p| styles << prop_map[p] }
|
|
884
|
+
colors = []
|
|
885
|
+
BORDER_COLORS.each { |p| colors << prop_map[p] }
|
|
886
|
+
|
|
887
|
+
# Check if all sides have same values and can create full border shorthand
|
|
888
|
+
# Check cheapest condition first (.all?), then do single pass for values/importance
|
|
889
|
+
widths_all_same = widths.all? && check_all_same?(widths)
|
|
890
|
+
styles_all_same = styles.all? && check_all_same?(styles)
|
|
891
|
+
colors_all_same = colors.all? && check_all_same?(colors)
|
|
892
|
+
|
|
893
|
+
# Can create FULL border shorthand ONLY if style is present (style is required for border shorthand)
|
|
894
|
+
# AND all properties that are present have same values and importance
|
|
895
|
+
if styles_all_same
|
|
896
|
+
# Check if we have width and/or color with same importance as style
|
|
897
|
+
can_create_border = true
|
|
898
|
+
important = styles.first.important
|
|
899
|
+
|
|
900
|
+
# If width is present, must be all-same and same importance
|
|
901
|
+
if widths.all?
|
|
902
|
+
can_create_border = false unless widths_all_same && widths.first.important == important
|
|
903
|
+
end
|
|
904
|
+
|
|
905
|
+
# If color is present, must be all-same and same importance
|
|
906
|
+
if colors.all?
|
|
907
|
+
can_create_border = false unless colors_all_same && colors.first.important == important
|
|
908
|
+
end
|
|
909
|
+
|
|
910
|
+
if can_create_border
|
|
911
|
+
# Create full border shorthand
|
|
912
|
+
parts = []
|
|
913
|
+
parts << widths.first.value if widths_all_same
|
|
914
|
+
parts << styles.first.value
|
|
915
|
+
parts << colors.first.value if colors_all_same
|
|
916
|
+
|
|
917
|
+
border_value = parts.join(' ')
|
|
918
|
+
|
|
919
|
+
# Remove individual properties and append shorthand
|
|
920
|
+
# Note: We append rather than insert at original position to match C implementation behavior
|
|
921
|
+
rule.declarations.reject! { |d| BORDER_ALL.include?(d.property) }
|
|
922
|
+
rule.declarations << Declaration.new(PROP_BORDER, border_value, important)
|
|
923
|
+
return
|
|
924
|
+
end
|
|
925
|
+
end
|
|
926
|
+
|
|
927
|
+
# Try to create border-width/style/color shorthands
|
|
928
|
+
recreate_border_width!(rule, widths) if widths.all?
|
|
929
|
+
recreate_border_style!(rule, styles) if styles.all?
|
|
930
|
+
recreate_border_color!(rule, colors) if colors.all?
|
|
931
|
+
end
|
|
932
|
+
|
|
933
|
+
# Recreate border-width shorthand
|
|
934
|
+
def self.recreate_border_width!(rule, widths)
|
|
935
|
+
importances = widths.map(&:important).uniq
|
|
936
|
+
return if importances.length > 1
|
|
937
|
+
|
|
938
|
+
values = widths.map(&:value)
|
|
939
|
+
important = widths.first.important
|
|
940
|
+
|
|
941
|
+
shorthand_value = optimize_four_sides(values)
|
|
942
|
+
|
|
943
|
+
rule.declarations.reject! { |d| BORDER_WIDTHS.include?(d.property) }
|
|
944
|
+
rule.declarations << Declaration.new(PROP_BORDER_WIDTH, shorthand_value, important)
|
|
945
|
+
end
|
|
946
|
+
|
|
947
|
+
# Recreate border-style shorthand
|
|
948
|
+
def self.recreate_border_style!(rule, styles)
|
|
949
|
+
importances = styles.map(&:important).uniq
|
|
950
|
+
return if importances.length > 1
|
|
951
|
+
|
|
952
|
+
values = styles.map(&:value)
|
|
953
|
+
important = styles.first.important
|
|
954
|
+
|
|
955
|
+
shorthand_value = optimize_four_sides(values)
|
|
956
|
+
|
|
957
|
+
rule.declarations.reject! { |d| BORDER_STYLES.include?(d.property) }
|
|
958
|
+
rule.declarations << Declaration.new(PROP_BORDER_STYLE, shorthand_value, important)
|
|
959
|
+
end
|
|
960
|
+
|
|
961
|
+
# Recreate border-color shorthand
|
|
962
|
+
def self.recreate_border_color!(rule, colors)
|
|
963
|
+
importances = colors.map(&:important).uniq
|
|
964
|
+
return if importances.length > 1
|
|
965
|
+
|
|
966
|
+
values = colors.map(&:value)
|
|
967
|
+
important = colors.first.important
|
|
968
|
+
|
|
969
|
+
shorthand_value = optimize_four_sides(values)
|
|
970
|
+
|
|
971
|
+
rule.declarations.reject! { |d| BORDER_COLORS.include?(d.property) }
|
|
972
|
+
rule.declarations << Declaration.new(PROP_BORDER_COLOR, shorthand_value, important)
|
|
973
|
+
end
|
|
974
|
+
|
|
975
|
+
# Optimize four-sided value representation
|
|
976
|
+
# ["10px", "10px", "10px", "10px"] -> "10px"
|
|
977
|
+
# ["10px", "20px", "10px", "20px"] -> "10px 20px"
|
|
978
|
+
# ["10px", "20px", "30px", "20px"] -> "10px 20px 30px"
|
|
979
|
+
# ["10px", "20px", "30px", "40px"] -> "10px 20px 30px 40px"
|
|
980
|
+
def self.optimize_four_sides(values)
|
|
981
|
+
top, right, bottom, left = values
|
|
982
|
+
|
|
983
|
+
if top == right && right == bottom && bottom == left
|
|
984
|
+
top
|
|
985
|
+
elsif top == bottom && right == left
|
|
986
|
+
"#{top} #{right}"
|
|
987
|
+
elsif right == left
|
|
988
|
+
"#{top} #{right} #{bottom}"
|
|
989
|
+
else
|
|
990
|
+
"#{top} #{right} #{bottom} #{left}"
|
|
991
|
+
end
|
|
992
|
+
end
|
|
993
|
+
|
|
994
|
+
# Try to recreate font shorthand
|
|
995
|
+
# Requires: font-size and font-family (minimum)
|
|
996
|
+
# Optional: font-style, font-variant, font-weight, line-height
|
|
997
|
+
def self.recreate_font!(rule, prop_map)
|
|
998
|
+
size = prop_map[PROP_FONT_SIZE]
|
|
999
|
+
family = prop_map[PROP_FONT_FAMILY]
|
|
1000
|
+
|
|
1001
|
+
# Need at least size and family
|
|
1002
|
+
return unless size && family
|
|
1003
|
+
|
|
1004
|
+
# Check if all font properties have same importance
|
|
1005
|
+
font_decls = FONT_PROPERTIES.filter_map { |p| prop_map[p] }
|
|
1006
|
+
return if font_decls.empty?
|
|
1007
|
+
|
|
1008
|
+
importances = font_decls.map(&:important).uniq
|
|
1009
|
+
return if importances.length > 1
|
|
1010
|
+
|
|
1011
|
+
important = font_decls.first.important
|
|
1012
|
+
|
|
1013
|
+
# Build font shorthand value
|
|
1014
|
+
# Strategy: Only omit defaults if we have ALL 6 properties (from shorthand expansion)
|
|
1015
|
+
# If we have a partial set, include all non-nil values
|
|
1016
|
+
style = prop_map[PROP_FONT_STYLE]&.value
|
|
1017
|
+
variant = prop_map[PROP_FONT_VARIANT]&.value
|
|
1018
|
+
weight = prop_map[PROP_FONT_WEIGHT]&.value
|
|
1019
|
+
line_height = prop_map[PROP_LINE_HEIGHT]&.value
|
|
1020
|
+
|
|
1021
|
+
all_present = style && variant && weight && line_height
|
|
1022
|
+
parts = []
|
|
1023
|
+
|
|
1024
|
+
if all_present
|
|
1025
|
+
# All properties present (likely from shorthand expansion) - omit defaults
|
|
1026
|
+
parts << style if style != 'normal'
|
|
1027
|
+
parts << variant if variant != 'normal'
|
|
1028
|
+
parts << weight if weight != 'normal'
|
|
1029
|
+
else
|
|
1030
|
+
# Partial set - include all non-nil values
|
|
1031
|
+
parts << style if style
|
|
1032
|
+
parts << variant if variant
|
|
1033
|
+
parts << weight if weight
|
|
1034
|
+
end
|
|
1035
|
+
|
|
1036
|
+
# Required: size[/line-height]
|
|
1037
|
+
if all_present
|
|
1038
|
+
# Omit line-height if default
|
|
1039
|
+
if line_height != 'normal'
|
|
1040
|
+
parts << "#{size.value}/#{line_height}"
|
|
1041
|
+
else
|
|
1042
|
+
parts << size.value
|
|
1043
|
+
end
|
|
1044
|
+
else
|
|
1045
|
+
# Include line-height if present
|
|
1046
|
+
if line_height
|
|
1047
|
+
parts << "#{size.value}/#{line_height}"
|
|
1048
|
+
else
|
|
1049
|
+
parts << size.value
|
|
1050
|
+
end
|
|
1051
|
+
end
|
|
1052
|
+
|
|
1053
|
+
# Required: family
|
|
1054
|
+
parts << family.value
|
|
1055
|
+
|
|
1056
|
+
shorthand_value = parts.join(' ')
|
|
1057
|
+
|
|
1058
|
+
# Remove individual properties and append shorthand
|
|
1059
|
+
# Note: We append rather than insert at original position to match C implementation behavior
|
|
1060
|
+
rule.declarations.reject! { |d| FONT_PROPERTIES.include?(d.property) }
|
|
1061
|
+
rule.declarations << Declaration.new(PROP_FONT, shorthand_value, important)
|
|
1062
|
+
end
|
|
1063
|
+
|
|
1064
|
+
# Try to recreate background shorthand
|
|
1065
|
+
# Can combine: background-color, background-image, background-repeat, etc.
|
|
1066
|
+
def self.recreate_background!(rule, prop_map)
|
|
1067
|
+
bg_props = BACKGROUND_PROPERTIES
|
|
1068
|
+
bg_decls = bg_props.filter_map { |p| prop_map[p] }
|
|
1069
|
+
|
|
1070
|
+
# Need at least 2 properties to create shorthand
|
|
1071
|
+
# Single properties should stay as longhands (e.g., background-color: blue)
|
|
1072
|
+
# because shorthand resets all other properties to initial values
|
|
1073
|
+
return if bg_decls.length < 2
|
|
1074
|
+
|
|
1075
|
+
# Check if all have same importance
|
|
1076
|
+
importances = bg_decls.map(&:important).uniq
|
|
1077
|
+
return if importances.length > 1
|
|
1078
|
+
|
|
1079
|
+
important = bg_decls.first.important
|
|
1080
|
+
|
|
1081
|
+
# Build background shorthand value
|
|
1082
|
+
# Strategy: Only omit defaults if we have ALL 5 properties (from shorthand expansion)
|
|
1083
|
+
# If we have a partial set (explicit longhands), include all values
|
|
1084
|
+
color = prop_map[PROP_BACKGROUND_COLOR]&.value
|
|
1085
|
+
image = prop_map[PROP_BACKGROUND_IMAGE]&.value
|
|
1086
|
+
repeat = prop_map[PROP_BACKGROUND_REPEAT]&.value
|
|
1087
|
+
position = prop_map[PROP_BACKGROUND_POSITION]&.value
|
|
1088
|
+
attachment = prop_map[PROP_BACKGROUND_ATTACHMENT]&.value
|
|
1089
|
+
|
|
1090
|
+
all_present = color && image && repeat && position && attachment
|
|
1091
|
+
parts = []
|
|
1092
|
+
|
|
1093
|
+
if all_present
|
|
1094
|
+
# All 5 properties present (likely from shorthand expansion) - omit defaults
|
|
1095
|
+
parts << color if color != 'transparent'
|
|
1096
|
+
parts << image if image != 'none'
|
|
1097
|
+
parts << repeat if repeat != 'repeat'
|
|
1098
|
+
parts << position if position != '0% 0%'
|
|
1099
|
+
parts << attachment if attachment != 'scroll'
|
|
1100
|
+
else
|
|
1101
|
+
# Partial set (explicit longhands) - include all non-nil values
|
|
1102
|
+
parts << color if color
|
|
1103
|
+
parts << image if image
|
|
1104
|
+
parts << repeat if repeat
|
|
1105
|
+
parts << position if position
|
|
1106
|
+
parts << attachment if attachment
|
|
1107
|
+
end
|
|
1108
|
+
|
|
1109
|
+
shorthand_value = parts.join(' ')
|
|
1110
|
+
|
|
1111
|
+
# Remove individual properties and append shorthand
|
|
1112
|
+
# Note: We append rather than insert at original position to match C implementation behavior
|
|
1113
|
+
rule.declarations.reject! { |d| BACKGROUND_PROPERTIES.include?(d.property) }
|
|
1114
|
+
rule.declarations << Declaration.new(PROP_BACKGROUND, shorthand_value, important)
|
|
1115
|
+
end
|
|
1116
|
+
|
|
1117
|
+
# Try to recreate list-style shorthand
|
|
1118
|
+
# Can combine: list-style-type, list-style-position, list-style-image
|
|
1119
|
+
def self.recreate_list_style!(rule, prop_map)
|
|
1120
|
+
ls_props = LIST_STYLE_PROPERTIES
|
|
1121
|
+
ls_decls = ls_props.filter_map { |p| prop_map[p] }
|
|
1122
|
+
|
|
1123
|
+
# Need at least 2 properties to create shorthand
|
|
1124
|
+
return if ls_decls.length < 2
|
|
1125
|
+
|
|
1126
|
+
# Check if all have same importance
|
|
1127
|
+
importances = ls_decls.map(&:important).uniq
|
|
1128
|
+
return if importances.length > 1
|
|
1129
|
+
|
|
1130
|
+
important = ls_decls.first.important
|
|
1131
|
+
|
|
1132
|
+
# Build list-style shorthand value
|
|
1133
|
+
parts = []
|
|
1134
|
+
parts << prop_map[PROP_LIST_STYLE_TYPE].value if prop_map[PROP_LIST_STYLE_TYPE]
|
|
1135
|
+
parts << prop_map[PROP_LIST_STYLE_POSITION].value if prop_map[PROP_LIST_STYLE_POSITION]
|
|
1136
|
+
parts << prop_map[PROP_LIST_STYLE_IMAGE].value if prop_map[PROP_LIST_STYLE_IMAGE]
|
|
1137
|
+
|
|
1138
|
+
shorthand_value = parts.join(' ')
|
|
1139
|
+
|
|
1140
|
+
# Remove individual properties and append shorthand
|
|
1141
|
+
# Note: We append rather than insert at original position to match C implementation behavior
|
|
1142
|
+
rule.declarations.reject! { |d| LIST_STYLE_PROPERTIES.include?(d.property) }
|
|
1143
|
+
rule.declarations << Declaration.new(PROP_LIST_STYLE, shorthand_value, important)
|
|
1144
|
+
end
|
|
1145
|
+
end
|
|
1146
|
+
end
|