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,425 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SilkLayout
4
+ module Layout
5
+ class FormattingBuilder
6
+ DEFAULT_DISPLAY = {
7
+ "html" => "block",
8
+ "head" => "none",
9
+ "meta" => "none",
10
+ "title" => "none",
11
+ "link" => "none",
12
+ "style" => "none",
13
+ "script" => "none",
14
+ "body" => "block",
15
+ "div" => "block",
16
+ "p" => "block",
17
+ "span" => "inline",
18
+ "a" => "inline",
19
+ "strong" => "inline",
20
+ "em" => "inline",
21
+ "br" => "inline",
22
+ "h1" => "block",
23
+ "h2" => "block",
24
+ "h3" => "block",
25
+ "h4" => "block",
26
+ "h5" => "block",
27
+ "h6" => "block",
28
+ "ul" => "block",
29
+ "ol" => "block",
30
+ "li" => "block",
31
+ "section" => "block",
32
+ "header" => "block",
33
+ "footer" => "block",
34
+ "nav" => "block",
35
+ "article" => "block"
36
+ }.freeze
37
+
38
+ def self.build(dom_root)
39
+ new.build(dom_root)
40
+ end
41
+
42
+ def build(dom_root)
43
+ build_box(dom_root)
44
+ end
45
+
46
+ private
47
+
48
+ def build_box(node)
49
+ box = create_box(node)
50
+ return nil unless box
51
+
52
+ inline_buffer = nil
53
+
54
+ node.children.each do |child|
55
+ child_box = build_box(child) || build_text(child)
56
+ next unless child_box
57
+
58
+ if box.is_a?(BlockBox) && child_box.is_a?(InlineBox)
59
+ inline_buffer ||= AnonymousBlockBox.new
60
+ inline_buffer.add_child(child_box)
61
+ else
62
+ if inline_buffer
63
+ box.add_child(inline_buffer)
64
+ inline_buffer = nil
65
+ end
66
+
67
+ box.add_child(child_box)
68
+ end
69
+ end
70
+
71
+ box.add_child(inline_buffer) if inline_buffer
72
+ box
73
+ end
74
+
75
+ def create_box(node)
76
+ return nil unless node
77
+ return nil unless node.respond_to?(:element?) && node.element?
78
+
79
+ style = node.computed_style
80
+ display = display_for(node, style)
81
+ return nil if display == "none"
82
+
83
+ box =
84
+ case display
85
+ when "block"
86
+ BlockBox.new(node)
87
+ when "flex", "inline-flex"
88
+ FlexBox.new(node)
89
+ when "inline"
90
+ InlineBox.new(node)
91
+ else
92
+ BlockBox.new(node)
93
+ end
94
+
95
+ box.display = display
96
+
97
+ if style.respond_to?(:explicit_width?) && style.explicit_width?
98
+ box.explicit_width = true
99
+ box.width = px(style["width"])
100
+ else
101
+ box.explicit_width = false
102
+ end
103
+
104
+ if style.respond_to?(:explicit_height?) && style.explicit_height?
105
+ box.explicit_height = true
106
+ box.height = px(style["height"])
107
+ else
108
+ box.explicit_height = false
109
+ end
110
+
111
+ box.margin = edge_lengths(style, "margin")
112
+ box.padding = edge_lengths(style, "padding")
113
+
114
+ border_styles = border_edges(style, "style", "none")
115
+ box.border = border_edges(style, "width", nil).transform_values { |value| px(value) }
116
+
117
+ box.border.each_key do |side|
118
+ box.border[side] = 0 if border_styles[side] == "none"
119
+ end
120
+
121
+ box.has_border = box.border.values.any? { |value| value > 0 }
122
+
123
+ default_color = box.has_border ? :black : nil
124
+
125
+ box.border_color = {
126
+ top: color(border_edges(style, "color", nil)[:top]) || default_color,
127
+ right: color(border_edges(style, "color", nil)[:right]) || default_color,
128
+ bottom: color(border_edges(style, "color", nil)[:bottom]) || default_color,
129
+ left: color(border_edges(style, "color", nil)[:left]) || default_color
130
+ }
131
+
132
+ box.background_color = color(background_color(style))
133
+ box.flex = flex_values(style)
134
+
135
+ box
136
+ end
137
+
138
+ def build_text(node)
139
+ return nil unless node.text
140
+
141
+ normalized_text = normalize_text(node)
142
+ return nil if normalized_text.nil? || normalized_text.empty?
143
+
144
+ style = node.computed_style || node.parent&.computed_style
145
+ TextBox.new(normalized_text, style)
146
+ end
147
+
148
+ def normalize_text(node)
149
+ text = node.text.to_s.gsub(/[[:space:]]+/, " ")
150
+ return nil if text.empty?
151
+
152
+ previous_inline = inline_neighbor?(node, :previous)
153
+ following_inline = inline_neighbor?(node, :next)
154
+
155
+ if text.strip.empty?
156
+ return " " if previous_inline && following_inline
157
+
158
+ return nil
159
+ end
160
+
161
+ text = text.lstrip unless previous_inline
162
+ text = text.rstrip unless following_inline
163
+
164
+ return nil if text.empty?
165
+
166
+ text
167
+ end
168
+
169
+ def inline_neighbor?(node, direction)
170
+ sibling = sibling_for(node, direction)
171
+
172
+ while sibling
173
+ return true if inline_level_node?(sibling)
174
+ return false if block_level_node?(sibling)
175
+
176
+ sibling = sibling_for(sibling, direction)
177
+ end
178
+
179
+ false
180
+ end
181
+
182
+ def sibling_for(node, direction)
183
+ siblings = node.parent&.children
184
+ return nil unless siblings
185
+
186
+ index = siblings.index(node)
187
+ return nil unless index
188
+
189
+ if direction == :previous
190
+ (index - 1).downto(0) do |candidate_index|
191
+ candidate = siblings[candidate_index]
192
+ return candidate unless candidate.nil?
193
+ end
194
+
195
+ nil
196
+ else
197
+ siblings[(index + 1)..]&.find { |candidate| !candidate.nil? }
198
+ end
199
+ end
200
+
201
+ def inline_level_node?(node)
202
+ return false unless node
203
+
204
+ if node.element?
205
+ display_for(node, node.computed_style) == "inline"
206
+ else
207
+ !collapsed_whitespace?(node.text)
208
+ end
209
+ end
210
+
211
+ def block_level_node?(node)
212
+ return false unless node&.element?
213
+
214
+ display = display_for(node, node.computed_style)
215
+ display != "inline" && display != "none"
216
+ end
217
+
218
+ def collapsed_whitespace?(text)
219
+ text.to_s.gsub(/[[:space:]]+/, " ").strip.empty?
220
+ end
221
+
222
+ def display_for(node, style)
223
+ if style.respond_to?(:explicit_display?) && style.explicit_display?
224
+ style["display"]
225
+ else
226
+ DEFAULT_DISPLAY.fetch(node.tag, style["display"])
227
+ end
228
+ end
229
+
230
+ def edge_lengths(style, property)
231
+ values = expanded_values(style[property])
232
+
233
+ {
234
+ top: px(style["#{property}-top"] || values[:top]),
235
+ right: px(style["#{property}-right"] || values[:right]),
236
+ bottom: px(style["#{property}-bottom"] || values[:bottom]),
237
+ left: px(style["#{property}-left"] || values[:left])
238
+ }
239
+ end
240
+
241
+ def border_edges(style, property, default)
242
+ border = border_shorthand(style["border"])
243
+ edges = expanded_values(style["border-#{property}"])
244
+
245
+ {
246
+ top: border_edge(style, :top, property, border, edges, default),
247
+ right: border_edge(style, :right, property, border, edges, default),
248
+ bottom: border_edge(style, :bottom, property, border, edges, default),
249
+ left: border_edge(style, :left, property, border, edges, default)
250
+ }
251
+ end
252
+
253
+ def border_edge(style, side, property, border, edges, default)
254
+ side_border = border_shorthand(style["border-#{side}"])
255
+
256
+ style["border-#{side}-#{property}"] ||
257
+ side_border[property] ||
258
+ edges[side] ||
259
+ border[property] ||
260
+ default
261
+ end
262
+
263
+ def expanded_values(value)
264
+ tokens = split_tokens(value)
265
+
266
+ case tokens.length
267
+ when 0
268
+ {top: nil, right: nil, bottom: nil, left: nil}
269
+ when 1
270
+ {top: tokens[0], right: tokens[0], bottom: tokens[0], left: tokens[0]}
271
+ when 2
272
+ {top: tokens[0], right: tokens[1], bottom: tokens[0], left: tokens[1]}
273
+ when 3
274
+ {top: tokens[0], right: tokens[1], bottom: tokens[2], left: tokens[1]}
275
+ else
276
+ {top: tokens[0], right: tokens[1], bottom: tokens[2], left: tokens[3]}
277
+ end
278
+ end
279
+
280
+ def border_shorthand(value)
281
+ split_tokens(value).each_with_object({}) do |token, parsed|
282
+ if border_width?(token)
283
+ parsed["width"] ||= token
284
+ elsif border_style?(token)
285
+ parsed["style"] ||= token
286
+ else
287
+ parsed["color"] ||= token
288
+ end
289
+ end
290
+ end
291
+
292
+ def background_color(style)
293
+ style["background-color"] || background_shorthand_color(style["background"])
294
+ end
295
+
296
+ def background_shorthand_color(value)
297
+ split_tokens(value).find { |token| color_token?(token) }
298
+ end
299
+
300
+ def flex_values(style)
301
+ shorthand = flex_shorthand(style["flex"])
302
+ row_gap, column_gap = gap_values(style)
303
+
304
+ {
305
+ direction: style["flex-direction"] || flex_flow(style)[:direction] || "row",
306
+ wrap: style["flex-wrap"] || flex_flow(style)[:wrap] || "nowrap",
307
+ justify_content: style["justify-content"] || "flex-start",
308
+ align_items: style["align-items"] || "stretch",
309
+ row_gap: row_gap,
310
+ column_gap: column_gap,
311
+ grow: number(style["flex-grow"] || shorthand[:grow], 0),
312
+ shrink: number(style["flex-shrink"] || shorthand[:shrink], 1),
313
+ basis: style["flex-basis"] || shorthand[:basis] || "auto"
314
+ }
315
+ end
316
+
317
+ def flex_shorthand(value)
318
+ tokens = split_tokens(value)
319
+ return {} if tokens.empty?
320
+
321
+ case tokens.join(" ")
322
+ when "none"
323
+ {grow: 0, shrink: 0, basis: "auto"}
324
+ when "auto"
325
+ {grow: 1, shrink: 1, basis: "auto"}
326
+ when "initial"
327
+ {grow: 0, shrink: 1, basis: "auto"}
328
+ else
329
+ numbers = tokens.select { |token| numeric?(token) }
330
+ basis = tokens.find { |token| !numeric?(token) }
331
+
332
+ {
333
+ grow: numbers[0] || 1,
334
+ shrink: numbers[1] || 1,
335
+ basis: basis || (numbers.any? ? "0px" : "auto")
336
+ }
337
+ end
338
+ end
339
+
340
+ def flex_flow(style)
341
+ split_tokens(style["flex-flow"]).each_with_object({}) do |token, parsed|
342
+ if %w[row row-reverse column column-reverse].include?(token)
343
+ parsed[:direction] = token
344
+ elsif %w[nowrap wrap wrap-reverse].include?(token)
345
+ parsed[:wrap] = token
346
+ end
347
+ end
348
+ end
349
+
350
+ def gap_values(style)
351
+ values = split_tokens(style["gap"])
352
+ row = values[0]
353
+ column = values[1] || values[0]
354
+
355
+ [
356
+ px(style["row-gap"] || row),
357
+ px(style["column-gap"] || column)
358
+ ]
359
+ end
360
+
361
+ def px(value)
362
+ return 0 unless value
363
+
364
+ raw = value.to_s.strip
365
+ return 0 if raw.empty? || raw == "auto"
366
+ return 1 if raw == "thin"
367
+ return 3 if raw == "medium"
368
+ return 5 if raw == "thick"
369
+
370
+ raw.delete_suffix("px").to_f
371
+ end
372
+
373
+ def color(value)
374
+ return nil unless value
375
+
376
+ value.to_sym
377
+ end
378
+
379
+ def number(value, default)
380
+ return default if value.nil?
381
+
382
+ raw = value.to_s.strip
383
+ return default if raw.empty?
384
+
385
+ raw.to_f
386
+ end
387
+
388
+ def split_tokens(value)
389
+ value.to_s.strip.split(/\s+/).reject(&:empty?)
390
+ end
391
+
392
+ def numeric?(value)
393
+ value.to_s.match?(/\A[-+]?\d*\.?\d+\z/)
394
+ end
395
+
396
+ def border_width?(value)
397
+ value.to_s.match?(/\A[-+]?\d*\.?\d+(?:px)?\z/) || %w[thin medium thick].include?(value.to_s)
398
+ end
399
+
400
+ def border_style?(value)
401
+ %w[
402
+ none
403
+ hidden
404
+ dotted
405
+ dashed
406
+ solid
407
+ double
408
+ groove
409
+ ridge
410
+ inset
411
+ outset
412
+ ].include?(value.to_s)
413
+ end
414
+
415
+ def color_token?(value)
416
+ raw = value.to_s.strip
417
+ return false if raw.empty?
418
+ return true if raw.start_with?("#")
419
+ return false if %w[none transparent inherit initial unset].include?(raw)
420
+
421
+ !border_width?(raw) && !border_style?(raw) && !raw.include?("(") && !raw.include?("/")
422
+ end
423
+ end
424
+ end
425
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SilkLayout
4
+ module Layout
5
+ class Inline < Box; end
6
+
7
+ class TextBox < InlineBox
8
+ attr_reader :text,
9
+ :font_size,
10
+ :font_family,
11
+ :font_weight,
12
+ :font_style,
13
+ :font_name,
14
+ :line_height,
15
+ :ascender,
16
+ :descender,
17
+ :color
18
+
19
+ def initialize(text, style)
20
+ super(nil)
21
+ @text = text
22
+
23
+ style ||= {}
24
+
25
+ @font_size = px(style["font-size"]) || 16
26
+ @font_family = style["font-family"] || "Helvetica"
27
+ @font_weight = style["font-weight"] || "normal"
28
+ @font_style = style["font-style"] || "normal"
29
+ @color = style["color"] || "black"
30
+
31
+ metrics = SilkLayout::Render::FontLibrary.metrics(
32
+ @font_family,
33
+ font_weight: @font_weight,
34
+ font_style: @font_style
35
+ )
36
+
37
+ @font_name = metrics[:font_name]
38
+ @ascender = metrics[:ascender] * @font_size / 1000.0
39
+ @descender = metrics[:descender] * @font_size / 1000.0
40
+ @line_height = parse_line_height(style["line-height"], @font_size)
41
+ end
42
+
43
+ def children
44
+ []
45
+ end
46
+
47
+ def clone_with_text(text)
48
+ self.class.new(text, {
49
+ "font-size" => "#{@font_size}px",
50
+ "font-family" => @font_family,
51
+ "font-weight" => @font_weight,
52
+ "font-style" => @font_style,
53
+ "line-height" => "#{@line_height}px",
54
+ "color" => @color
55
+ })
56
+ end
57
+
58
+ private
59
+
60
+ def px(value)
61
+ return nil unless value
62
+
63
+ value.to_s.delete_suffix("px").to_f
64
+ end
65
+
66
+ def parse_line_height(value, font_size)
67
+ return (font_size * 1.2).round(2) if value.nil?
68
+
69
+ raw = value.to_s.strip
70
+ return (font_size * 1.2).round(2) if raw.empty? || raw == "normal"
71
+
72
+ if raw.end_with?("px")
73
+ raw.delete_suffix("px").to_f
74
+ elsif raw.end_with?("%")
75
+ font_size * raw.delete_suffix("%").to_f / 100.0
76
+ else
77
+ font_size * raw.to_f
78
+ end
79
+ end
80
+ end
81
+
82
+ class LineBox < Box
83
+ def initialize
84
+ super(nil)
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SilkLayout
4
+ module Layout
5
+ class InlineFormatter
6
+ Fragment = Struct.new(:box, :text, :width, :height, :break_after) do
7
+ def whitespace?
8
+ text&.match?(/\A\s+\z/)
9
+ end
10
+ end
11
+
12
+ class << self
13
+ def layout(inline_children, available_width, parent_x, parent_y)
14
+ fragments = flatten(inline_children)
15
+ lines = []
16
+ current_fragments = []
17
+ current_y = parent_y
18
+
19
+ fragments.each do |fragment|
20
+ if fragment.break_after
21
+ line = flush_line(current_fragments, parent_x, current_y)
22
+ if line
23
+ lines << line
24
+ current_y += line.height
25
+ end
26
+ current_fragments = []
27
+ next
28
+ end
29
+
30
+ if fragment.whitespace? && current_fragments.empty?
31
+ next
32
+ end
33
+
34
+ current_width = current_fragments.sum(&:width)
35
+ overflow = !current_fragments.empty? && (current_width + fragment.width) > available_width
36
+
37
+ if overflow
38
+ line = flush_line(current_fragments, parent_x, current_y)
39
+ if line
40
+ lines << line
41
+ current_y += line.height
42
+ end
43
+ current_fragments = []
44
+ end
45
+
46
+ next if fragment.whitespace? && current_fragments.empty?
47
+
48
+ current_fragments << fragment
49
+ end
50
+
51
+ line = flush_line(current_fragments, parent_x, current_y)
52
+ lines << line if line
53
+ lines
54
+ end
55
+
56
+ private
57
+
58
+ def flush_line(fragments, parent_x, parent_y)
59
+ fragments = trim_trailing_whitespace(fragments)
60
+ return if fragments.empty?
61
+
62
+ line = LineBox.new
63
+ cursor_x = 0
64
+ baseline = fragments.map { |fragment| fragment.box.ascender if fragment.box.is_a?(TextBox) }.compact.max || 0
65
+
66
+ fragments.each do |fragment|
67
+ child = fragment.box
68
+ child.x = parent_x + cursor_x
69
+ child.y = parent_y + baseline - child.ascender
70
+ child.width = fragment.width
71
+ child.height = fragment.height
72
+ cursor_x += child.width
73
+ line.add_child(child)
74
+ end
75
+
76
+ line.width = cursor_x
77
+ line.height = fragments.map(&:height).max || 0
78
+ line.x = parent_x
79
+ line.y = parent_y
80
+ line
81
+ end
82
+
83
+ def trim_trailing_whitespace(fragments)
84
+ trimmed = fragments.dup
85
+ trimmed.pop while trimmed.last&.whitespace?
86
+ trimmed
87
+ end
88
+
89
+ def flatten(boxes)
90
+ boxes.flat_map { |box| flatten_box(box) }
91
+ end
92
+
93
+ def flatten_box(box)
94
+ case box
95
+ when TextBox
96
+ tokenize_text(box)
97
+ when InlineBox
98
+ if box.node&.tag == "br"
99
+ [Fragment.new(box: box, break_after: true)]
100
+ else
101
+ flatten(box.children)
102
+ end
103
+ else
104
+ []
105
+ end
106
+ end
107
+
108
+ def tokenize_text(box)
109
+ box.text.scan(/\s+|\S+/).map do |token|
110
+ fragment_box = box.clone_with_text(token)
111
+ Fragment.new(
112
+ box: fragment_box,
113
+ text: token,
114
+ width: measure_text(fragment_box),
115
+ height: fragment_box.line_height
116
+ )
117
+ end
118
+ end
119
+
120
+ def measure_text(box)
121
+ SilkLayout::Render::FontLibrary.measure_text(
122
+ box.text,
123
+ font_size: box.font_size,
124
+ font_family: box.font_family,
125
+ font_weight: box.font_weight,
126
+ font_style: box.font_style
127
+ )
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SilkLayout
4
+ module Layout
5
+ class Root
6
+ def self.find(box)
7
+ current = box
8
+ while current.node&.tag == "html" || current.node&.tag == "body"
9
+ current = current.children.first
10
+ end
11
+ current
12
+ end
13
+ end
14
+ end
15
+ end