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.
- 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
|