prawn-markup 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,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