prawn-markup 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +12 -0
- data/.rspec +3 -0
- data/.rubocop.yml +64 -0
- data/.travis.yml +25 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +4 -0
- data/Gemfile.lock +89 -0
- data/LICENSE.txt +21 -0
- data/README.md +106 -0
- data/Rakefile +6 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/lib/prawn/markup.rb +23 -0
- data/lib/prawn/markup/builders/list_builder.rb +165 -0
- data/lib/prawn/markup/builders/nestable_builder.rb +68 -0
- data/lib/prawn/markup/builders/table_builder.rb +253 -0
- data/lib/prawn/markup/elements/cell.rb +15 -0
- data/lib/prawn/markup/elements/item.rb +17 -0
- data/lib/prawn/markup/elements/list.rb +14 -0
- data/lib/prawn/markup/interface.rb +17 -0
- data/lib/prawn/markup/processor.rb +145 -0
- data/lib/prawn/markup/processor/headings.rb +64 -0
- data/lib/prawn/markup/processor/images.rb +91 -0
- data/lib/prawn/markup/processor/lists.rb +95 -0
- data/lib/prawn/markup/processor/tables.rb +97 -0
- data/lib/prawn/markup/processor/text.rb +176 -0
- data/lib/prawn/markup/support/hash_merger.rb +19 -0
- data/lib/prawn/markup/support/normalizer.rb +48 -0
- data/lib/prawn/markup/support/size_converter.rb +31 -0
- data/lib/prawn/markup/version.rb +5 -0
- data/prawn-markup.gemspec +37 -0
- metadata +217 -0
data/lib/prawn/markup.rb
ADDED
@@ -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
|