silk_layout 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +20 -0
- data/LICENSE +21 -0
- data/README.md +154 -0
- data/lib/silk_layout/css/cascade.rb +49 -0
- data/lib/silk_layout/css/computed_style.rb +83 -0
- data/lib/silk_layout/css/parser.rb +50 -0
- data/lib/silk_layout/css/properties.rb +19 -0
- data/lib/silk_layout/css/rule.rb +15 -0
- data/lib/silk_layout/css/selector.rb +165 -0
- data/lib/silk_layout/html/node.rb +56 -0
- data/lib/silk_layout/html/parser.rb +188 -0
- data/lib/silk_layout/layout/block_layout.rb +93 -0
- data/lib/silk_layout/layout/box.rb +80 -0
- data/lib/silk_layout/layout/box_builder.rb +11 -0
- data/lib/silk_layout/layout/context.rb +13 -0
- data/lib/silk_layout/layout/engine.rb +22 -0
- data/lib/silk_layout/layout/flex_layout.rb +508 -0
- data/lib/silk_layout/layout/formatting_builder.rb +425 -0
- data/lib/silk_layout/layout/inline.rb +88 -0
- data/lib/silk_layout/layout/inline_formatter.rb +132 -0
- data/lib/silk_layout/layout/root.rb +15 -0
- data/lib/silk_layout/render/font_library.rb +127 -0
- data/lib/silk_layout/render/painter.rb +247 -0
- data/lib/silk_layout/render/pdf_renderer.rb +31 -0
- data/lib/silk_layout/version.rb +5 -0
- data/lib/silk_layout.rb +55 -0
- metadata +251 -0
|
@@ -0,0 +1,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
|