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,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "hexapdf"
4
+
5
+ module SilkLayout
6
+ module Render
7
+ class FontLibrary
8
+ BASE_FONTS = {
9
+ helvetica: {
10
+ normal: {
11
+ normal: "Helvetica",
12
+ italic: "Helvetica-Oblique"
13
+ },
14
+ bold: {
15
+ normal: "Helvetica-Bold",
16
+ italic: "Helvetica-BoldOblique"
17
+ }
18
+ },
19
+ times: {
20
+ normal: {
21
+ normal: "Times-Roman",
22
+ italic: "Times-Italic"
23
+ },
24
+ bold: {
25
+ normal: "Times-Bold",
26
+ italic: "Times-BoldItalic"
27
+ }
28
+ },
29
+ courier: {
30
+ normal: {
31
+ normal: "Courier",
32
+ italic: "Courier-Oblique"
33
+ },
34
+ bold: {
35
+ normal: "Courier-Bold",
36
+ italic: "Courier-BoldOblique"
37
+ }
38
+ }
39
+ }.freeze
40
+
41
+ FAMILY_ALIASES = {
42
+ "arial" => :helvetica,
43
+ "helvetica" => :helvetica,
44
+ "sans-serif" => :helvetica,
45
+ "sans serif" => :helvetica,
46
+ "times" => :times,
47
+ "times new roman" => :times,
48
+ "serif" => :times,
49
+ "courier" => :courier,
50
+ "courier new" => :courier,
51
+ "monospace" => :courier
52
+ }.freeze
53
+
54
+ class << self
55
+ def metrics(font_family, font_weight: "normal", font_style: "normal")
56
+ font_name = resolve_font_name(font_family, font_weight: font_weight, font_style: font_style)
57
+ font = font_wrapper(font_name)
58
+
59
+ {
60
+ font_name: font_name,
61
+ font: font,
62
+ ascender: normalized_metric(font, :ascender),
63
+ descender: normalized_metric(font, :descender)
64
+ }
65
+ end
66
+
67
+ def measure_text(text, font_size:, font_family:, font_weight: "normal", font_style: "normal")
68
+ return 0 if text.to_s.empty?
69
+
70
+ font = metrics(font_family, font_weight: font_weight, font_style: font_style)[:font]
71
+ glyph_width = font.decode_utf8(text.to_s).sum(&:width)
72
+ glyph_width * font_size / 1000.0
73
+ end
74
+
75
+ def resolve_font_name(font_family, font_weight: "normal", font_style: "normal")
76
+ family = normalized_family(font_family)
77
+ weight = bold?(font_weight) ? :bold : :normal
78
+ style = italic?(font_style) ? :italic : :normal
79
+
80
+ BASE_FONTS.fetch(family, BASE_FONTS[:helvetica]).fetch(weight).fetch(style)
81
+ end
82
+
83
+ private
84
+
85
+ def font_wrapper(font_name)
86
+ @document ||= HexaPDF::Document.new
87
+ @fonts ||= {}
88
+ @fonts[font_name] ||= @document.fonts.add(font_name)
89
+ end
90
+
91
+ def normalized_metric(font, metric_name)
92
+ wrapped_font = font.instance_variable_get(:@wrapped_font)
93
+ return 0 unless wrapped_font
94
+
95
+ wrapped_font.public_send(metric_name).to_f * font.scaling_factor
96
+ end
97
+
98
+ def normalized_family(font_family)
99
+ candidates(font_family).each do |candidate|
100
+ mapped = FAMILY_ALIASES[candidate]
101
+ return mapped if mapped
102
+ end
103
+
104
+ :helvetica
105
+ end
106
+
107
+ def candidates(font_family)
108
+ font_family.to_s.split(",").map do |family|
109
+ family.to_s.strip.delete_prefix('"').delete_suffix('"').delete_prefix("'").delete_suffix("'").downcase
110
+ end.reject(&:empty?)
111
+ end
112
+
113
+ def bold?(font_weight)
114
+ weight = font_weight.to_s.strip.downcase
115
+ return true if weight == "bold"
116
+ return false if weight.empty? || weight == "normal"
117
+
118
+ weight.to_i >= 600
119
+ end
120
+
121
+ def italic?(font_style)
122
+ %w[italic oblique].include?(font_style.to_s.strip.downcase)
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,247 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SilkLayout
4
+ module Render
5
+ class Painter
6
+ def self.paint(canvas, box, page_height_pt)
7
+ paint_box(canvas, box, page_height_pt)
8
+ end
9
+
10
+ def self.paint_box(canvas, box, page_height_pt)
11
+ draw_background(canvas, box, page_height_pt)
12
+ draw_borders(canvas, box, page_height_pt)
13
+
14
+ if box.is_a?(SilkLayout::Layout::TextBox)
15
+ paint_text(canvas, box, page_height_pt)
16
+ end
17
+
18
+ box.children.each do |child|
19
+ paint_box(canvas, child, page_height_pt)
20
+ end
21
+ end
22
+
23
+ def self.paint_text(canvas, box, page_height_pt)
24
+ font_size_px = box.font_size || 16
25
+ font_name = box.font_name || box.font_family || "Helvetica"
26
+
27
+ font_size_pt = PdfRenderer.px_to_pt(font_size_px)
28
+ ascent_pt = PdfRenderer.px_to_pt(box.respond_to?(:ascender) ? box.ascender : font_size_px * 0.8)
29
+
30
+ canvas.font(font_name, size: font_size_pt)
31
+
32
+ canvas = set_color(canvas, color_to_symbol(box.respond_to?(:color) ? box.color : nil) || :black)
33
+
34
+ x_pt = PdfRenderer.px_to_pt(box.x)
35
+ y_pt = page_height_pt - PdfRenderer.px_to_pt(box.y) - ascent_pt
36
+
37
+ canvas.text(box.text, at: [x_pt, y_pt])
38
+ end
39
+
40
+ def self.draw_background(canvas, box, page_height_pt)
41
+ return if box.is_a?(SilkLayout::Layout::AnonymousBlockBox)
42
+ return unless box.respond_to?(:background_color) && box.background_color
43
+
44
+ rgb = rgb_color(box.background_color)
45
+ return unless rgb
46
+
47
+ x = PdfRenderer.px_to_pt(box.border_box_x)
48
+ y = page_height_pt - PdfRenderer.px_to_pt(box.border_box_y + box.border_box_height)
49
+ w = PdfRenderer.px_to_pt(box.border_box_width)
50
+ h = PdfRenderer.px_to_pt(box.border_box_height)
51
+
52
+ canvas.fill_color(*rgb)
53
+ canvas.rectangle(x, y, w, h).fill
54
+ end
55
+
56
+ def self.draw_borders(canvas, box, page_height_pt)
57
+ return if box.is_a?(SilkLayout::Layout::AnonymousBlockBox)
58
+ return unless box.has_border
59
+
60
+ x = PdfRenderer.px_to_pt(box.border_box_x)
61
+ y = page_height_pt - PdfRenderer.px_to_pt(box.border_box_y + box.border_box_height)
62
+ w = PdfRenderer.px_to_pt(box.border_box_width)
63
+ h = PdfRenderer.px_to_pt(box.border_box_height)
64
+
65
+ top = PdfRenderer.px_to_pt(box.border[:top])
66
+ bottom = PdfRenderer.px_to_pt(box.border[:bottom])
67
+ left = PdfRenderer.px_to_pt(box.border[:left])
68
+ right = PdfRenderer.px_to_pt(box.border[:right])
69
+
70
+ colors = [box.border_color[:top], box.border_color[:right], box.border_color[:bottom], box.border_color[:left]]
71
+ if colors.uniq.length <= 1
72
+ if top > 0
73
+ canvas = set_color(canvas, box.border_color[:top])
74
+ canvas.rectangle(x, y + h - top, w, top).fill
75
+ end
76
+
77
+ if bottom > 0
78
+ canvas = set_color(canvas, box.border_color[:bottom])
79
+ canvas.rectangle(x, y, w, bottom).fill
80
+ end
81
+
82
+ if left > 0
83
+ canvas = set_color(canvas, box.border_color[:left])
84
+ canvas.rectangle(x, y, left, h).fill
85
+ end
86
+
87
+ if right > 0
88
+ canvas = set_color(canvas, box.border_color[:right])
89
+ canvas.rectangle(x + w - right, y, right, h).fill
90
+ end
91
+
92
+ return
93
+ end
94
+
95
+ inner_w = [w - left - right, 0].max
96
+ inner_h = [h - top - bottom, 0].max
97
+
98
+ if top > 0 && inner_w > 0
99
+ canvas = set_color(canvas, box.border_color[:top])
100
+ canvas.rectangle(x + left, y + h - top, inner_w, top).fill
101
+ end
102
+
103
+ if bottom > 0 && inner_w > 0
104
+ canvas = set_color(canvas, box.border_color[:bottom])
105
+ canvas.rectangle(x + left, y, inner_w, bottom).fill
106
+ end
107
+
108
+ if left > 0 && inner_h > 0
109
+ canvas = set_color(canvas, box.border_color[:left])
110
+ canvas.rectangle(x, y + bottom, left, inner_h).fill
111
+ end
112
+
113
+ if right > 0 && inner_h > 0
114
+ canvas = set_color(canvas, box.border_color[:right])
115
+ canvas.rectangle(x + w - right, y + bottom, right, inner_h).fill
116
+ end
117
+
118
+ draw_corner(
119
+ canvas,
120
+ x,
121
+ y + h - top,
122
+ left,
123
+ top,
124
+ box.border_color[:left],
125
+ box.border_color[:top],
126
+ :top_left
127
+ )
128
+
129
+ draw_corner(
130
+ canvas,
131
+ x + w - right,
132
+ y + h - top,
133
+ right,
134
+ top,
135
+ box.border_color[:right],
136
+ box.border_color[:top],
137
+ :top_right
138
+ )
139
+
140
+ draw_corner(
141
+ canvas,
142
+ x,
143
+ y,
144
+ left,
145
+ bottom,
146
+ box.border_color[:left],
147
+ box.border_color[:bottom],
148
+ :bottom_left
149
+ )
150
+
151
+ draw_corner(
152
+ canvas,
153
+ x + w - right,
154
+ y,
155
+ right,
156
+ bottom,
157
+ box.border_color[:right],
158
+ box.border_color[:bottom],
159
+ :bottom_right
160
+ )
161
+ end
162
+
163
+ def self.set_color(canvas, color)
164
+ return canvas unless color
165
+
166
+ rgb = rgb_color(color)
167
+ if rgb
168
+ canvas.fill_color(*rgb)
169
+ else
170
+ canvas.fill_color(0, 0, 0)
171
+ end
172
+
173
+ canvas
174
+ end
175
+
176
+ def self.color_to_symbol(color)
177
+ return nil unless color
178
+
179
+ normalized = color.to_s.strip
180
+ return nil if normalized.empty?
181
+
182
+ normalized.to_sym
183
+ end
184
+
185
+ 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) }
200
+ end
201
+
202
+ def self.draw_corner(canvas, x, y, w, h, vertical_color, horizontal_color, kind)
203
+ return if w <= 0 || h <= 0
204
+
205
+ if vertical_color == horizontal_color
206
+ canvas = set_color(canvas, vertical_color)
207
+ canvas.rectangle(x, y, w, h).fill
208
+ return
209
+ end
210
+
211
+ case kind
212
+ when :top_left
213
+ fill_triangle(canvas, horizontal_color, [x, y + h], [x + w, y + h], [x + w, y])
214
+ fill_triangle(canvas, vertical_color, [x, y + h], [x + w, y], [x, y])
215
+ when :top_right
216
+ fill_triangle(canvas, horizontal_color, [x, y + h], [x + w, y + h], [x, y])
217
+ fill_triangle(canvas, vertical_color, [x + w, y + h], [x + w, y], [x, y])
218
+ when :bottom_left
219
+ fill_triangle(canvas, horizontal_color, [x, y], [x + w, y], [x + w, y + h])
220
+ fill_triangle(canvas, vertical_color, [x, y], [x + w, y + h], [x, y + h])
221
+ when :bottom_right
222
+ fill_triangle(canvas, horizontal_color, [x, y], [x + w, y], [x, y + h])
223
+ fill_triangle(canvas, vertical_color, [x + w, y], [x + w, y + h], [x, y + h])
224
+ end
225
+ end
226
+
227
+ def self.fill_triangle(canvas, color, p1, p2, p3)
228
+ canvas = set_color(canvas, color)
229
+ canvas.move_to(*p1)
230
+ canvas.line_to(*p2)
231
+ canvas.line_to(*p3)
232
+ canvas.close_subpath.fill
233
+ end
234
+
235
+ 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
+ end
246
+ end
247
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "hexapdf"
4
+
5
+ module SilkLayout
6
+ module Render
7
+ class PdfRenderer
8
+ CSS_DPI = 96.0
9
+ PDF_DPI = 72.0
10
+
11
+ PAGE_WIDTH_PX = 800
12
+ PAGE_HEIGHT_PX = 1000
13
+
14
+ def self.render(box_tree, output_path)
15
+ doc = HexaPDF::Document.new
16
+
17
+ page_width_pt = px_to_pt(PAGE_WIDTH_PX)
18
+ page_height_pt = px_to_pt(PAGE_HEIGHT_PX)
19
+
20
+ page = doc.pages.add([0, 0, page_width_pt, page_height_pt])
21
+ Painter.paint(page.canvas, box_tree, page_height_pt)
22
+
23
+ doc.write(output_path)
24
+ end
25
+
26
+ def self.px_to_pt(px)
27
+ px * PDF_DPI / CSS_DPI
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SilkLayout
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/all"
4
+
5
+ module SilkLayout
6
+ autoload :VERSION, "silk_layout/version"
7
+
8
+ module HTML
9
+ autoload :Parser, "silk_layout/html/parser"
10
+ autoload :Node, "silk_layout/html/node"
11
+ end
12
+
13
+ module CSS
14
+ autoload :Parser, "silk_layout/css/parser"
15
+ autoload :Cascade, "silk_layout/css/cascade"
16
+ autoload :ComputedStyle, "silk_layout/css/computed_style"
17
+ autoload :Rule, "silk_layout/css/rule"
18
+ autoload :Declaration, "silk_layout/css/rule"
19
+ autoload :Selector, "silk_layout/css/selector"
20
+ autoload :Properties, "silk_layout/css/properties"
21
+ end
22
+
23
+ module Layout
24
+ autoload :Box, "silk_layout/layout/box"
25
+ autoload :BlockBox, "silk_layout/layout/box"
26
+ autoload :FlexBox, "silk_layout/layout/box"
27
+ autoload :InlineBox, "silk_layout/layout/box"
28
+ autoload :AnonymousBlockBox, "silk_layout/layout/box"
29
+ autoload :Inline, "silk_layout/layout/inline"
30
+ autoload :TextBox, "silk_layout/layout/inline"
31
+ autoload :LineBox, "silk_layout/layout/inline"
32
+ autoload :InlineFormatter, "silk_layout/layout/inline_formatter"
33
+ autoload :FormattingBuilder, "silk_layout/layout/formatting_builder"
34
+ autoload :BoxBuilder, "silk_layout/layout/box_builder"
35
+ autoload :Context, "silk_layout/layout/context"
36
+ autoload :BlockLayout, "silk_layout/layout/block_layout"
37
+ autoload :FlexLayout, "silk_layout/layout/flex_layout"
38
+ autoload :Engine, "silk_layout/layout/engine"
39
+ autoload :Root, "silk_layout/layout/root"
40
+ end
41
+
42
+ module Render
43
+ autoload :FontLibrary, "silk_layout/render/font_library"
44
+ autoload :Painter, "silk_layout/render/painter"
45
+ autoload :PdfRenderer, "silk_layout/render/pdf_renderer"
46
+ end
47
+
48
+ def self.render_document(html_document, out, url: nil)
49
+ dom, stylesheets = SilkLayout::HTML::Parser.parse_document(html_document, url: url)
50
+ rules = SilkLayout::CSS::Parser.parse_all(stylesheets)
51
+ box_tree = SilkLayout::Layout::Engine.layout(dom, rules)
52
+
53
+ SilkLayout::Render::PdfRenderer.render(box_tree, out)
54
+ end
55
+ end