prawn-markup 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,23 @@
1
+ require 'prawn'
2
+ require 'prawn/measurement_extensions'
3
+ require 'prawn/table'
4
+ require 'nokogiri'
5
+ require 'prawn/markup/support/hash_merger'
6
+ require 'prawn/markup/support/size_converter'
7
+ require 'prawn/markup/support/normalizer'
8
+ require 'prawn/markup/elements/item'
9
+ require 'prawn/markup/elements/cell'
10
+ require 'prawn/markup/elements/list'
11
+ require 'prawn/markup/builders/nestable_builder'
12
+ require 'prawn/markup/builders/list_builder'
13
+ require 'prawn/markup/builders/table_builder'
14
+ require 'prawn/markup/processor'
15
+ require 'prawn/markup/interface'
16
+ require 'prawn/markup/version'
17
+
18
+ module Prawn
19
+ module Markup
20
+ end
21
+ end
22
+
23
+ Prawn::Document.extensions << Prawn::Markup::Interface
@@ -0,0 +1,165 @@
1
+ module Prawn
2
+ module Markup
3
+ module Builders
4
+ class ListBuilder < NestableBuilder
5
+ BULLET_CHAR = '•'.freeze
6
+ BULLET_MARGIN = 10
7
+ CONTENT_MARGIN = 10
8
+ VERTICAL_MARGIN = 5
9
+
10
+ def initialize(pdf, list, total_width, options = {})
11
+ super(pdf, total_width, options)
12
+ @list = list
13
+ @column_widths = compute_column_widths
14
+ end
15
+
16
+ def make(main = false)
17
+ pdf.make_table(convert_list, list_table_options) do |t|
18
+ t.columns(0).style(column_cell_style(:bullet))
19
+ t.columns(1).style(column_cell_style(:content))
20
+ set_paddings(t, main)
21
+ end
22
+ end
23
+
24
+ def draw
25
+ make(true).draw
26
+ end
27
+
28
+ private
29
+
30
+ attr_reader :list, :column_widths
31
+
32
+ def list_table_options
33
+ {
34
+ column_widths: column_widths,
35
+ cell_style: { border_width: 0, inline_format: true }
36
+ }
37
+ end
38
+
39
+ def set_paddings(table, main)
40
+ set_row_padding(table, [0, 0, padding_bottom])
41
+ if main
42
+ set_row_padding(table.rows(0), [vertical_margin, 0, padding_bottom])
43
+ set_row_padding(table.rows(-1), [0, 0, padding_bottom + vertical_margin])
44
+ else
45
+ set_row_padding(table.rows(-1), [0, 0, 0])
46
+ end
47
+ end
48
+
49
+ def set_row_padding(row, padding)
50
+ row.columns(0).padding = [*padding, bullet_margin]
51
+ row.columns(1).padding = [*padding, content_margin]
52
+ end
53
+
54
+ def convert_list
55
+ list.items.map.with_index do |item, i|
56
+ if item.single?
57
+ [bullet(i + 1), normalize_list_item_node(item.nodes.first)]
58
+ else
59
+ [bullet(i + 1), list_item_table(item)]
60
+ end
61
+ end
62
+ end
63
+
64
+ def list_item_table(item)
65
+ data = item.nodes.map { |n| [normalize_list_item_node(n)] }
66
+ style = column_cell_style(:content)
67
+ .merge(borders: [], padding: [0, 0, padding_bottom, 0])
68
+ pdf.make_table(data, cell_style: style) do
69
+ rows(-1).padding = [0, 0, 0, 0]
70
+ end
71
+ end
72
+
73
+ def normalize_list_item_node(node)
74
+ normalizer = "item_node_for_#{type_key(node)}"
75
+ if respond_to?(normalizer, true)
76
+ send(normalizer, node)
77
+ else
78
+ ''
79
+ end
80
+ end
81
+
82
+ def item_node_for_list(node)
83
+ # sublist
84
+ ListBuilder.new(pdf, node, content_width, options).make
85
+ end
86
+
87
+ def item_node_for_hash(node)
88
+ normalize_cell_hash(node, content_width)
89
+ end
90
+
91
+ def item_node_for_string(node)
92
+ node
93
+ end
94
+
95
+ def content_width
96
+ column_widths.last && column_widths.last - content_margin
97
+ end
98
+
99
+ def compute_column_widths
100
+ return [] if list.items.empty?
101
+
102
+ bullet_width = bullet_text_width + bullet_margin
103
+ text_width = total_width && (total_width - bullet_width)
104
+ [bullet_width, text_width]
105
+ end
106
+
107
+ def bullet_text_width
108
+ font = bullet_font
109
+ font_size = column_cell_style(:bullet)[:size] || pdf.font_size
110
+ encoded = font.normalize_encoding(bullet(list.items.size))
111
+ font.compute_width_of(encoded, size: font_size)
112
+ end
113
+
114
+ def bullet_font
115
+ style = column_cell_style(:bullet)
116
+ font_name = style[:font] || pdf.font.family
117
+ pdf.find_font(font_name, style: style[:font_style])
118
+ end
119
+
120
+ # option accessors
121
+
122
+ def bullet(index)
123
+ list.ordered ? "#{index}." : (column_cell_style(:bullet)[:char] || BULLET_CHAR)
124
+ end
125
+
126
+ # margin before bullet
127
+ def bullet_margin
128
+ column_cell_style(:bullet)[:margin] || BULLET_MARGIN
129
+ end
130
+
131
+ # margin between bullet and content
132
+ def content_margin
133
+ column_cell_style(:content)[:margin] || CONTENT_MARGIN
134
+ end
135
+
136
+ # margin at the top and the bottom of the list
137
+ def vertical_margin
138
+ list_options[:vertical_margin] || VERTICAL_MARGIN
139
+ end
140
+
141
+ # vertical padding between list items
142
+ def padding_bottom
143
+ column_cell_style(:content)[:leading] || 0
144
+ end
145
+
146
+ def column_cell_style(key)
147
+ @column_cell_styles ||= {}
148
+ @column_cell_styles[key] ||=
149
+ extract_text_cell_style(options[:text] || {}).merge(list_options[key])
150
+ end
151
+
152
+ def list_options
153
+ @list_options ||= HashMerger.deep(default_list_options, options[:list] || {})
154
+ end
155
+
156
+ def default_list_options
157
+ {
158
+ content: {},
159
+ bullet: { align: :right }
160
+ }
161
+ end
162
+ end
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,68 @@
1
+ module Prawn
2
+ module Markup
3
+ module Builders
4
+ class NestableBuilder
5
+ TEXT_STYLE_OPTIONS = %i[font size style font_style color text_color
6
+ kerning leading align min_font_size overflow rotate
7
+ rotate_around single_line valign].freeze
8
+
9
+ def initialize(pdf, total_width, options = {})
10
+ @pdf = pdf
11
+ @total_width = total_width
12
+ @options = options
13
+ end
14
+
15
+ private
16
+
17
+ attr_reader :pdf, :total_width, :options
18
+
19
+ def normalize_cell_hash(hash, cell_width, style_options = {})
20
+ if hash.key?(:image)
21
+ compute_image_width(hash, cell_width)
22
+ else
23
+ style_options.merge(hash)
24
+ end
25
+ end
26
+
27
+ def text_options
28
+ (options[:text] || {})
29
+ end
30
+
31
+ def compute_image_width(hash, max_width)
32
+ hash.dup.tap do |image_hash|
33
+ image_hash.delete(:width)
34
+ image_hash[:image_width] = SizeConverter.new(max_width).parse(hash[:width])
35
+ if max_width
36
+ natural_width, _height = natural_image_dimensions(image_hash)
37
+ image_hash[:fit] = [max_width, 999_999] if max_width < natural_width
38
+ end
39
+ end
40
+ end
41
+
42
+ def natural_image_dimensions(hash)
43
+ _obj, info = pdf.build_image_object(hash[:image])
44
+ info.calc_image_dimensions(width: hash[:image_width])
45
+ end
46
+
47
+ def extract_text_cell_style(hash)
48
+ TEXT_STYLE_OPTIONS
49
+ .each_with_object({}) { |key, h| h[key] = hash[key] }
50
+ .tap do |options|
51
+ options[:font_style] ||= options.delete(:style)
52
+ options[:text_color] ||= options.delete(:color)
53
+ end
54
+ end
55
+
56
+ def type_key(object)
57
+ path = object.class.name.to_s
58
+ i = path.rindex('::')
59
+ if i
60
+ path[(i + 2)..-1].downcase
61
+ else
62
+ path.downcase
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,253 @@
1
+ module Prawn
2
+ module Markup
3
+ module Builders
4
+ class TableBuilder < NestableBuilder
5
+ FAILOVER_STRATEGIES = %i[equal_widths subtable_placeholders].freeze
6
+
7
+ DEFAULT_CELL_PADDING = 5
8
+
9
+ MIN_COL_WIDTH = 1.cm
10
+
11
+ def initialize(pdf, cells, total_width, options = {})
12
+ super(pdf, total_width, options)
13
+ @cells = cells
14
+ @column_widths = []
15
+ end
16
+
17
+ def make
18
+ compute_column_widths
19
+ pdf.make_table(convert_cells, prawn_table_options)
20
+ end
21
+
22
+ def draw
23
+ make.draw
24
+ rescue Prawn::Errors::CannotFit => e
25
+ if failover_on_error
26
+ draw
27
+ else
28
+ raise e
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ attr_reader :cells, :column_widths, :failover_strategy
35
+
36
+ def prawn_table_options
37
+ static_prawn_table_options.tap do |options|
38
+ options[:width] = total_width
39
+ options[:header] = cells.first && cells.first.all?(&:header)
40
+ options[:column_widths] = column_widths
41
+ end
42
+ end
43
+
44
+ def static_prawn_table_options
45
+ table_options.dup.tap do |options|
46
+ options.delete(:placeholder)
47
+ options.delete(:header)
48
+ TEXT_STYLE_OPTIONS.each { |key| options[:cell].delete(key) }
49
+ options[:cell_style] = options.delete(:cell)
50
+ end
51
+ end
52
+
53
+ def convert_cells
54
+ cells.map do |row|
55
+ row.map.with_index do |cell, col|
56
+ convert_cell(cell, col)
57
+ end
58
+ end
59
+ end
60
+
61
+ def convert_cell(cell, col)
62
+ style_options = table_options[cell.header ? :header : :cell]
63
+ if cell.single?
64
+ normalize_cell_node(cell.nodes.first, column_content_width(col), style_options)
65
+ else
66
+ cell_table(cell, column_content_width(col), style_options)
67
+ end
68
+ end
69
+
70
+ def column_content_width(col)
71
+ width = column_widths[col]
72
+ width -= horizontal_padding if width
73
+ width
74
+ end
75
+
76
+ # cell with multiple nodes is represented as single-column table
77
+ def cell_table(cell, width, style_options)
78
+ data = cell.nodes.map { |n| [normalize_cell_node(n, width, style_options)] }
79
+ pdf.make_table(data,
80
+ width: width,
81
+ cell_style: {
82
+ padding: [0, 0, 0, 0],
83
+ borders: [],
84
+ border_width: 0,
85
+ inline_format: true
86
+ })
87
+ end
88
+
89
+ def normalize_cell_node(node, width, style_options = {})
90
+ normalizer = "cell_node_for_#{type_key(node)}"
91
+ if respond_to?(normalizer, true)
92
+ send(normalizer, node, width, style_options)
93
+ else
94
+ ''
95
+ end
96
+ end
97
+
98
+ def cell_node_for_list(node, width, _style_options = {})
99
+ opts = options.merge(text: extract_text_cell_style(table_options[:cell]))
100
+ subtable(width) do
101
+ ListBuilder.new(pdf, node, width, opts).make(true)
102
+ end
103
+ end
104
+
105
+ def cell_node_for_array(node, width, _style_options = {})
106
+ subtable(width) do
107
+ TableBuilder.new(pdf, node, width, options).make
108
+ end
109
+ end
110
+
111
+ def cell_node_for_hash(node, width, style_options = {})
112
+ normalize_cell_hash(node, width, style_options)
113
+ end
114
+
115
+ def cell_node_for_string(node, _width, style_options = {})
116
+ style_options.merge(content: node)
117
+ end
118
+
119
+ def subtable(width)
120
+ if width.nil? && failover_strategy == :subtable_placeholders
121
+ { content: table_options[:placeholder][:subtable_too_large] }
122
+ else
123
+ yield
124
+ end
125
+ end
126
+
127
+ def normalize_cell_hash(node, width, style_options)
128
+ if width.nil? && total_width
129
+ width = total_width - column_width_sum - (columns_without_width - 1) * MIN_COL_WIDTH
130
+ end
131
+ super(node, width, style_options)
132
+ end
133
+
134
+ def compute_column_widths
135
+ parse_given_widths
136
+ if total_width
137
+ add_missing_widths
138
+ stretch_to_total_width
139
+ end
140
+ end
141
+
142
+ def parse_given_widths
143
+ return if cells.empty?
144
+
145
+ @column_widths = Array.new(cells.first.size)
146
+ converter = SizeConverter.new(total_width)
147
+ cells.each do |row|
148
+ row.each_with_index do |cell, col|
149
+ @column_widths[col] ||= converter.parse(cell.width)
150
+ end
151
+ end
152
+ end
153
+
154
+ def add_missing_widths
155
+ missing_count = columns_without_width
156
+ if missing_count == 1 ||
157
+ (missing_count > 1 && failover_strategy == :equal_widths)
158
+ distribute_remaing_width(missing_count)
159
+ end
160
+ end
161
+
162
+ def columns_without_width
163
+ column_widths.count(&:nil?)
164
+ end
165
+
166
+ def column_width_sum
167
+ column_widths.compact.inject(:+) || 0
168
+ end
169
+
170
+ def distribute_remaing_width(count)
171
+ equal_width = (total_width - column_width_sum) / count.to_f
172
+ return if equal_width < 0
173
+ column_widths.map! { |width| width || equal_width }
174
+ end
175
+
176
+ def stretch_to_total_width
177
+ sum = column_width_sum
178
+ if columns_without_width.zero? && sum < total_width
179
+ increase_widths(sum)
180
+ elsif sum > total_width
181
+ decrease_widths(sum)
182
+ end
183
+ end
184
+
185
+ def increase_widths(sum)
186
+ diff = total_width - sum
187
+ column_widths.map! { |w| w + w / sum * diff }
188
+ end
189
+
190
+ def decrease_widths(sum)
191
+ sum += columns_without_width * MIN_COL_WIDTH
192
+ diff = sum - total_width
193
+ column_widths.map! { |w| w ? [w - w / sum * diff, 0].max : nil }
194
+ end
195
+
196
+ def failover_on_error
197
+ if failover_strategy == FAILOVER_STRATEGIES.last
198
+ @failover_strategy = nil
199
+ else
200
+ index = FAILOVER_STRATEGIES.index(failover_strategy) || -1
201
+ @failover_strategy = FAILOVER_STRATEGIES[index + 1]
202
+ end
203
+ end
204
+
205
+ def horizontal_padding
206
+ @horizontal_padding ||= begin
207
+ padding = table_options[:cell][:padding] || [DEFAULT_CELL_PADDING] * 4
208
+ padding.is_a?(Array) ? padding[1] + padding[3] : padding
209
+ end
210
+ end
211
+
212
+ def table_options
213
+ @table_options ||= build_table_options
214
+ end
215
+
216
+ def build_table_options
217
+ HashMerger.deep(default_table_options, options[:table] || {}).tap do |opts|
218
+ build_cell_options(opts)
219
+ build_header_options(opts)
220
+ end
221
+ end
222
+
223
+ def build_cell_options(opts)
224
+ HashMerger.enhance(opts, :cell, extract_text_cell_style(options[:text] || {}))
225
+ convert_style_options(opts[:cell])
226
+ end
227
+
228
+ def build_header_options(opts)
229
+ HashMerger.enhance(opts, :header, opts[:cell])
230
+ convert_style_options(opts[:header])
231
+ end
232
+
233
+ def convert_style_options(hash)
234
+ hash[:font_style] ||= hash.delete(:style)
235
+ hash[:text_color] ||= hash.delete(:color)
236
+ end
237
+
238
+ def default_table_options
239
+ {
240
+ cell: {
241
+ inline_format: true,
242
+ padding: [DEFAULT_CELL_PADDING] * 4
243
+ },
244
+ header: {},
245
+ placeholder: {
246
+ subtable_too_large: '[nested tables with automatic width are not supported]'
247
+ }
248
+ }
249
+ end
250
+ end
251
+ end
252
+ end
253
+ end