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.
@@ -4,6 +4,9 @@ module SilkLayout
4
4
  module Layout
5
5
  class BlockLayout
6
6
  def self.layout(box, context, cursor_y = 0, parent_x = 0, containing_width = nil)
7
+ available_width = containing_width || context.width
8
+ resolve_spacing(box, available_width)
9
+
7
10
  return FlexLayout.layout(box, context, cursor_y, parent_x, containing_width) if box.is_a?(FlexBox)
8
11
 
9
12
  box.x = parent_x + box.margin[:left]
@@ -15,17 +18,7 @@ module SilkLayout
15
18
  content_y =
16
19
  box.y + box.border[:top] + box.padding[:top]
17
20
 
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
21
+ content_width = resolved_content_width(box, available_width)
29
22
 
30
23
  current_y = content_y
31
24
  new_children = []
@@ -71,7 +64,9 @@ module SilkLayout
71
64
  box.children = new_children
72
65
 
73
66
  content_height = current_y - content_y
74
- content_height = [content_height, box.height].max if box.explicit_height
67
+ if box.explicit_height
68
+ content_height = [content_height, content_height_from_css_height(box, declared_height(box))].max
69
+ end
75
70
 
76
71
  max_child_width =
77
72
  box.children.map(&:width).max || 0
@@ -88,6 +83,130 @@ module SilkLayout
88
83
  box.padding[:top] + box.padding[:bottom] +
89
84
  box.border[:top] + box.border[:bottom]
90
85
  end
86
+
87
+ def self.resolve_spacing(box, available_width)
88
+ box.margin = resolved_edges(box, "margin", available_width, box.margin)
89
+ box.padding = resolved_edges(box, "padding", available_width, box.padding)
90
+ end
91
+
92
+ def self.resolved_edges(box, property, available_width, fallback)
93
+ style = box.node&.computed_style
94
+ values = CSS::Values.expanded_edges(style&.[](property))
95
+
96
+ {
97
+ top: resolved_edge(style, property, :top, values, available_width, fallback[:top]),
98
+ right: resolved_edge(style, property, :right, values, available_width, fallback[:right]),
99
+ bottom: resolved_edge(style, property, :bottom, values, available_width, fallback[:bottom]),
100
+ left: resolved_edge(style, property, :left, values, available_width, fallback[:left])
101
+ }
102
+ end
103
+
104
+ def self.resolved_edge(style, property, side, values, available_width, fallback)
105
+ raw = style&.[]("#{property}-#{side}") || values[side]
106
+ return fallback unless raw
107
+
108
+ CSS::Values.resolve_length(raw, reference: available_width, default: fallback)
109
+ end
110
+
111
+ def self.resolved_content_width(box, available_width)
112
+ content_width =
113
+ if box.explicit_width
114
+ content_width_from_css_width(box, declared_width(box, available_width))
115
+ else
116
+ auto_content_width(box, available_width)
117
+ end
118
+
119
+ [content_width, 0].max
120
+ end
121
+
122
+ def self.declared_width(box, available_width)
123
+ raw = style_value(box, "width")
124
+ width =
125
+ if raw && raw != "auto" && !preserve_assigned_width?(box, raw, available_width)
126
+ CSS::Values.resolve_length(raw, reference: available_width, default: box.width)
127
+ else
128
+ box.width
129
+ end
130
+
131
+ constrain_css_width(box, width, available_width)
132
+ end
133
+
134
+ def self.auto_content_width(box, available_width)
135
+ content_width =
136
+ available_width -
137
+ box.margin[:left] - box.margin[:right] -
138
+ box.border[:left] - box.border[:right] -
139
+ box.padding[:left] - box.padding[:right]
140
+
141
+ constrained_width =
142
+ if border_box_sizing?(box)
143
+ constrain_css_width(box, content_width + horizontal_box_edges(box), available_width)
144
+ else
145
+ constrain_css_width(box, content_width, available_width)
146
+ end
147
+
148
+ content_width_from_css_width(box, constrained_width)
149
+ end
150
+
151
+ def self.constrain_css_width(box, width, available_width)
152
+ min_width = optional_width(box, "min-width", available_width)
153
+ max_width = optional_width(box, "max-width", available_width)
154
+
155
+ width = [width, min_width].max if min_width
156
+ width = [width, max_width].min if max_width
157
+ width
158
+ end
159
+
160
+ def self.optional_width(box, property, available_width)
161
+ raw = style_value(box, property)
162
+ return nil if raw.nil? || raw == "none" || raw == "auto"
163
+
164
+ CSS::Values.resolve_length(raw, reference: available_width, default: 0)
165
+ end
166
+
167
+ def self.content_width_from_css_width(box, width)
168
+ return width unless border_box_sizing?(box)
169
+
170
+ width - horizontal_box_edges(box)
171
+ end
172
+
173
+ def self.declared_height(box)
174
+ raw = style_value(box, "height")
175
+ return box.height if raw.nil? || raw == "auto"
176
+
177
+ CSS::Values.resolve_length(raw, default: box.height)
178
+ end
179
+
180
+ def self.content_height_from_css_height(box, height)
181
+ content_height = border_box_sizing?(box) ? height - vertical_box_edges(box) : height
182
+
183
+ [content_height, 0].max
184
+ end
185
+
186
+ def self.border_box_sizing?(box)
187
+ style_value(box, "box-sizing") == "border-box"
188
+ end
189
+
190
+ def self.horizontal_box_edges(box)
191
+ box.border[:left] + box.border[:right] + box.padding[:left] + box.padding[:right]
192
+ end
193
+
194
+ def self.vertical_box_edges(box)
195
+ box.border[:top] + box.border[:bottom] + box.padding[:top] + box.padding[:bottom]
196
+ end
197
+
198
+ def self.preserve_assigned_width?(box, raw_width, available_width)
199
+ return false if CSS::Values.reference_relative?(raw_width)
200
+
201
+ available_width && box.width == available_width
202
+ end
203
+
204
+ def self.style_value(box, property)
205
+ value = box.node&.computed_style&.[](property)
206
+ return nil if value.to_s.strip.empty?
207
+
208
+ value
209
+ end
91
210
  end
92
211
  end
93
212
  end
@@ -18,7 +18,12 @@ module SilkLayout
18
18
  :explicit_height,
19
19
  :flex,
20
20
  :display,
21
- :has_border
21
+ :has_border,
22
+ :replaced,
23
+ :image_source,
24
+ :image_resource,
25
+ :intrinsic_width,
26
+ :intrinsic_height
22
27
 
23
28
  def initialize(node)
24
29
  @node = node
@@ -44,6 +49,11 @@ module SilkLayout
44
49
  @explicit_height = false
45
50
  @flex = {}
46
51
  @display = nil
52
+ @replaced = false
53
+ @image_source = nil
54
+ @image_resource = nil
55
+ @intrinsic_width = nil
56
+ @intrinsic_height = nil
47
57
  end
48
58
 
49
59
  def add_child(box)
@@ -65,6 +75,34 @@ module SilkLayout
65
75
  def border_box_height
66
76
  height
67
77
  end
78
+
79
+ def content_box_x
80
+ x + border[:left] + padding[:left]
81
+ end
82
+
83
+ def content_box_y
84
+ y + border[:top] + padding[:top]
85
+ end
86
+
87
+ def content_box_width
88
+ [width - border[:left] - border[:right] - padding[:left] - padding[:right], 0].max
89
+ end
90
+
91
+ def content_box_height
92
+ [height - border[:top] - border[:bottom] - padding[:top] - padding[:bottom], 0].max
93
+ end
94
+
95
+ def replaced?
96
+ replaced
97
+ end
98
+
99
+ def image?
100
+ !image_resource.nil?
101
+ end
102
+
103
+ def image_path
104
+ image_resource&.path
105
+ end
68
106
  end
69
107
 
70
108
  class BlockBox < Box; end
@@ -3,10 +3,15 @@
3
3
  module SilkLayout
4
4
  module Layout
5
5
  class Context
6
- attr_reader :width
6
+ attr_reader :width, :page_size
7
7
 
8
- def initialize(width:)
8
+ def initialize(width:, page_size: nil)
9
9
  @width = width
10
+ @page_size = page_size
11
+ end
12
+
13
+ def viewport_width
14
+ width
10
15
  end
11
16
  end
12
17
  end
@@ -5,18 +5,28 @@ module SilkLayout
5
5
  class Engine
6
6
  DEFAULT_VIEWPORT_WIDTH = 800
7
7
 
8
- def self.layout(dom, css_rules, viewport_width: DEFAULT_VIEWPORT_WIDTH)
8
+ def self.layout(dom, css_rules, viewport_width: nil, page_size: nil)
9
9
  CSS::Cascade.apply(dom, css_rules)
10
10
 
11
11
  box_tree = FormattingBuilder.build(dom)
12
12
 
13
13
  root = Root.find(box_tree)
14
14
 
15
- context = Context.new(width: viewport_width)
15
+ context = Context.new(width: viewport_width || page_width(page_size) || DEFAULT_VIEWPORT_WIDTH, page_size: page_size)
16
16
  BlockLayout.layout(root, context)
17
17
 
18
18
  root
19
19
  end
20
+
21
+ def self.page_width(page_size)
22
+ case page_size
23
+ when Array
24
+ page_size[0]
25
+ when Hash
26
+ page_size[:width] || page_size["width"]
27
+ end
28
+ end
29
+ private_class_method :page_width
20
30
  end
21
31
  end
22
32
  end
@@ -282,6 +282,7 @@ module SilkLayout
282
282
  end
283
283
 
284
284
  BlockLayout.layout(child, @context, 0, 0, width)
285
+ child.height = [height, 0].max + vertical_box_edges(child) if height
285
286
  end
286
287
 
287
288
  def row_base_content(child)
@@ -19,6 +19,7 @@ module SilkLayout
19
19
  "strong" => "inline",
20
20
  "em" => "inline",
21
21
  "br" => "inline",
22
+ "img" => "inline",
22
23
  "h1" => "block",
23
24
  "h2" => "block",
24
25
  "h3" => "block",
@@ -131,10 +132,75 @@ module SilkLayout
131
132
 
132
133
  box.background_color = color(background_color(style))
133
134
  box.flex = flex_values(style)
135
+ apply_image_data(box, node, style)
134
136
 
135
137
  box
136
138
  end
137
139
 
140
+ def apply_image_data(box, node, style)
141
+ return unless node.tag == "img"
142
+
143
+ source = node.resolved_source_url || node.attributes["src"]
144
+ image = SilkLayout::Resource::Image.load(source)
145
+
146
+ box.replaced = true
147
+ box.image_source = source.to_s
148
+ box.image_resource = image
149
+ box.intrinsic_width = image&.width
150
+ box.intrinsic_height = image&.height
151
+
152
+ box.width, box.height = replaced_dimensions(style, node.attributes, image)
153
+ box.explicit_width = true
154
+ box.explicit_height = true
155
+ end
156
+
157
+ def replaced_dimensions(style, attributes, image)
158
+ attr_width = html_dimension(attributes["width"])
159
+ attr_height = html_dimension(attributes["height"])
160
+ css_width = css_replaced_dimension(style, "width")
161
+ css_height = css_replaced_dimension(style, "height")
162
+
163
+ width = css_width.nil? ? attr_width : css_width
164
+ height = css_height.nil? ? attr_height : css_height
165
+ ratio = image&.aspect_ratio || aspect_ratio(attr_width, attr_height)
166
+
167
+ if width.nil? && height.nil?
168
+ width = image&.width || 0
169
+ height = image&.height || 0
170
+ elsif width.nil?
171
+ width = ratio ? height * ratio : (image&.width || 0)
172
+ elsif height.nil?
173
+ height = ratio ? width / ratio : (image&.height || 0)
174
+ end
175
+
176
+ [width.to_f, height.to_f]
177
+ end
178
+
179
+ def html_dimension(value)
180
+ raw = value.to_s.strip
181
+ return nil if raw.empty?
182
+
183
+ raw = raw.delete_suffix("px")
184
+ return nil unless raw.match?(/\A[-+]?\d*\.?\d+\z/)
185
+
186
+ raw.to_f
187
+ end
188
+
189
+ def aspect_ratio(width, height)
190
+ return nil unless width&.positive? && height&.positive?
191
+
192
+ width.to_f / height
193
+ end
194
+
195
+ def css_replaced_dimension(style, property)
196
+ return nil unless style.public_send("explicit_#{property}?")
197
+
198
+ raw = style[property]
199
+ return nil if CSS::Values.reference_relative?(raw)
200
+
201
+ CSS::Values.resolve_length(raw, default: nil)
202
+ end
203
+
138
204
  def build_text(node)
139
205
  return nil unless node.text
140
206
 
@@ -261,20 +327,7 @@ module SilkLayout
261
327
  end
262
328
 
263
329
  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
330
+ CSS::Values.expanded_edges(value)
278
331
  end
279
332
 
280
333
  def border_shorthand(value)
@@ -371,9 +424,10 @@ module SilkLayout
371
424
  end
372
425
 
373
426
  def color(value)
374
- return nil unless value
427
+ parsed = SilkLayout::CSS::Color.parse(value)
428
+ return nil unless parsed
375
429
 
376
- value.to_sym
430
+ parsed.to_sym
377
431
  end
378
432
 
379
433
  def number(value, default)
@@ -386,7 +440,7 @@ module SilkLayout
386
440
  end
387
441
 
388
442
  def split_tokens(value)
389
- value.to_s.strip.split(/\s+/).reject(&:empty?)
443
+ CSS::Values.split_tokens(value)
390
444
  end
391
445
 
392
446
  def numeric?(value)
@@ -415,8 +469,8 @@ module SilkLayout
415
469
  def color_token?(value)
416
470
  raw = value.to_s.strip
417
471
  return false if raw.empty?
418
- return true if raw.start_with?("#")
419
- return false if %w[none transparent inherit initial unset].include?(raw)
472
+ return true if SilkLayout::CSS::Color.parse(raw)
473
+ return false if %w[none inherit initial unset].include?(raw)
420
474
 
421
475
  !border_width?(raw) && !border_style?(raw) && !raw.include?("(") && !raw.include?("/")
422
476
  end
@@ -3,7 +3,7 @@
3
3
  module SilkLayout
4
4
  module Layout
5
5
  class InlineFormatter
6
- Fragment = Struct.new(:box, :text, :width, :height, :break_after) do
6
+ Fragment = Struct.new(:box, :text, :width, :height, :break_after, :ascender) do
7
7
  def whitespace?
8
8
  text&.match?(/\A\s+\z/)
9
9
  end
@@ -11,7 +11,7 @@ module SilkLayout
11
11
 
12
12
  class << self
13
13
  def layout(inline_children, available_width, parent_x, parent_y)
14
- fragments = flatten(inline_children)
14
+ fragments = flatten(inline_children, available_width)
15
15
  lines = []
16
16
  current_fragments = []
17
17
  current_y = parent_y
@@ -61,12 +61,13 @@ module SilkLayout
61
61
 
62
62
  line = LineBox.new
63
63
  cursor_x = 0
64
- baseline = fragments.map { |fragment| fragment.box.ascender if fragment.box.is_a?(TextBox) }.compact.max || 0
64
+ baseline = fragments.map { |fragment| fragment_ascender(fragment) }.max || 0
65
+ descent = fragments.map { |fragment| fragment.height - fragment_ascender(fragment) }.max || 0
65
66
 
66
67
  fragments.each do |fragment|
67
68
  child = fragment.box
68
69
  child.x = parent_x + cursor_x
69
- child.y = parent_y + baseline - child.ascender
70
+ child.y = parent_y + baseline - fragment_ascender(fragment)
70
71
  child.width = fragment.width
71
72
  child.height = fragment.height
72
73
  cursor_x += child.width
@@ -74,7 +75,7 @@ module SilkLayout
74
75
  end
75
76
 
76
77
  line.width = cursor_x
77
- line.height = fragments.map(&:height).max || 0
78
+ line.height = baseline + descent
78
79
  line.x = parent_x
79
80
  line.y = parent_y
80
81
  line
@@ -86,25 +87,98 @@ module SilkLayout
86
87
  trimmed
87
88
  end
88
89
 
89
- def flatten(boxes)
90
- boxes.flat_map { |box| flatten_box(box) }
90
+ def flatten(boxes, available_width)
91
+ boxes.flat_map { |box| flatten_box(box, available_width) }
91
92
  end
92
93
 
93
- def flatten_box(box)
94
+ def flatten_box(box, available_width)
94
95
  case box
95
96
  when TextBox
96
97
  tokenize_text(box)
97
98
  when InlineBox
98
- if box.node&.tag == "br"
99
+ if box.respond_to?(:replaced?) && box.replaced?
100
+ [replaced_fragment(box, available_width)]
101
+ elsif box.node&.tag == "br"
99
102
  [Fragment.new(box: box, break_after: true)]
100
103
  else
101
- flatten(box.children)
104
+ flatten(box.children, available_width)
102
105
  end
103
106
  else
104
107
  []
105
108
  end
106
109
  end
107
110
 
111
+ def replaced_fragment(box, available_width)
112
+ content_width, content_height = replaced_content_size(box, available_width)
113
+ width = content_width + horizontal_box_edges(box)
114
+ height = content_height + vertical_box_edges(box)
115
+
116
+ Fragment.new(
117
+ box: box,
118
+ width: width,
119
+ height: height,
120
+ ascender: height
121
+ )
122
+ end
123
+
124
+ def replaced_content_size(box, available_width)
125
+ width = resolved_replaced_width(box, available_width)
126
+ height = resolved_replaced_height(box)
127
+ ratio = aspect_ratio(box)
128
+
129
+ if width.nil? && height
130
+ width = ratio ? height * ratio : box.width
131
+ elsif height.nil? && width
132
+ height = ratio ? width / ratio : box.height
133
+ end
134
+
135
+ [width || box.width, height || box.height].map { |value| [value, 0].max }
136
+ end
137
+
138
+ def resolved_replaced_width(box, available_width)
139
+ raw = style_value(box, "width")
140
+ return box.width if raw.nil? || raw == "auto"
141
+
142
+ width = CSS::Values.resolve_length(raw, reference: available_width, default: box.width)
143
+ border_box_sizing?(box) ? width - horizontal_box_edges(box) : width
144
+ end
145
+
146
+ def resolved_replaced_height(box)
147
+ raw = style_value(box, "height")
148
+ return nil if raw.nil? || raw == "auto"
149
+
150
+ height = CSS::Values.resolve_length(raw, default: box.height)
151
+ border_box_sizing?(box) ? height - vertical_box_edges(box) : height
152
+ end
153
+
154
+ def aspect_ratio(box)
155
+ image_ratio = box.image_resource&.aspect_ratio
156
+ return image_ratio if image_ratio
157
+
158
+ return nil unless box.width.positive? && box.height.positive?
159
+
160
+ box.width.to_f / box.height
161
+ end
162
+
163
+ def border_box_sizing?(box)
164
+ style_value(box, "box-sizing") == "border-box"
165
+ end
166
+
167
+ def horizontal_box_edges(box)
168
+ box.border[:left] + box.border[:right] + box.padding[:left] + box.padding[:right]
169
+ end
170
+
171
+ def vertical_box_edges(box)
172
+ box.border[:top] + box.border[:bottom] + box.padding[:top] + box.padding[:bottom]
173
+ end
174
+
175
+ def style_value(box, property)
176
+ value = box.node&.computed_style&.[](property)
177
+ return nil if value.to_s.strip.empty?
178
+
179
+ value
180
+ end
181
+
108
182
  def tokenize_text(box)
109
183
  box.text.scan(/\s+|\S+/).map do |token|
110
184
  fragment_box = box.clone_with_text(token)
@@ -126,6 +200,10 @@ module SilkLayout
126
200
  font_style: box.font_style
127
201
  )
128
202
  end
203
+
204
+ def fragment_ascender(fragment)
205
+ fragment.ascender || (fragment.box.is_a?(TextBox) ? fragment.box.ascender : fragment.height)
206
+ end
129
207
  end
130
208
  end
131
209
  end
@@ -4,11 +4,10 @@ module SilkLayout
4
4
  module Layout
5
5
  class Root
6
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
7
+ return nil unless box
8
+ return box unless box.node&.tag == "html"
9
+
10
+ box.children.find { |child| child.node&.tag == "body" } || box
12
11
  end
13
12
  end
14
13
  end
@@ -9,6 +9,7 @@ module SilkLayout
9
9
 
10
10
  def self.paint_box(canvas, box, page_height_pt)
11
11
  draw_background(canvas, box, page_height_pt)
12
+ paint_image(canvas, box, page_height_pt) if box.respond_to?(:image?) && box.image?
12
13
  draw_borders(canvas, box, page_height_pt)
13
14
 
14
15
  if box.is_a?(SilkLayout::Layout::TextBox)
@@ -37,6 +38,21 @@ module SilkLayout
37
38
  canvas.text(box.text, at: [x_pt, y_pt])
38
39
  end
39
40
 
41
+ def self.paint_image(canvas, box, page_height_pt)
42
+ return unless box.image_path && File.file?(box.image_path)
43
+
44
+ width = box.content_box_width
45
+ height = box.content_box_height
46
+ return unless width.positive? && height.positive?
47
+
48
+ x_pt = PdfRenderer.px_to_pt(box.content_box_x)
49
+ y_pt = page_height_pt - PdfRenderer.px_to_pt(box.content_box_y + height)
50
+ width_pt = PdfRenderer.px_to_pt(width)
51
+ height_pt = PdfRenderer.px_to_pt(height)
52
+
53
+ canvas.image(box.image_path, at: [x_pt, y_pt], width: width_pt, height: height_pt)
54
+ end
55
+
40
56
  def self.draw_background(canvas, box, page_height_pt)
41
57
  return if box.is_a?(SilkLayout::Layout::AnonymousBlockBox)
42
58
  return unless box.respond_to?(:background_color) && box.background_color
@@ -183,20 +199,7 @@ module SilkLayout
183
199
  end
184
200
 
185
201
  def self.rgb_color(color)
186
- normalized = color.to_s.strip.downcase
187
- return nil if normalized.empty? || normalized == "transparent"
188
-
189
- return hex_color(normalized) if normalized.start_with?("#")
190
-
191
- NAMED_COLORS[normalized.to_sym]
192
- end
193
-
194
- def self.hex_color(color)
195
- hex = color.delete_prefix("#")
196
- hex = hex.chars.flat_map { |char| [char, char] }.join if hex.length == 3
197
- return nil unless hex.match?(/\A[0-9a-f]{6}\z/)
198
-
199
- hex.scan(/../).map { |component| component.to_i(16) }
202
+ SilkLayout::CSS::Color.rgb(color)
200
203
  end
201
204
 
202
205
  def self.draw_corner(canvas, x, y, w, h, vertical_color, horizontal_color, kind)
@@ -233,15 +236,6 @@ module SilkLayout
233
236
  end
234
237
 
235
238
  private_class_method :paint_box
236
-
237
- NAMED_COLORS = {
238
- black: [0, 0, 0],
239
- blue: [0, 0, 255],
240
- green: [0, 128, 0],
241
- lightblue: [173, 216, 230],
242
- red: [255, 0, 0],
243
- white: [255, 255, 255]
244
- }.freeze
245
239
  end
246
240
  end
247
241
  end