tn_pdf 0.0.2 → 0.0.8

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.
data/lib/tn_pdf/table.rb CHANGED
@@ -1,28 +1,51 @@
1
1
  require 'tn_pdf/table_column'
2
- require 'prawn'
3
2
 
4
3
  module TnPDF
4
+ # A representation {Report}'s table. It's an abstraction above a Prawn
5
+ # table that that 'defaultizes' a commonly used structure - a table that is a
6
+ # {#collection} of elements displayed as {#rows}, on which each of the {#columns}
7
+ # is a property of an object.
8
+ #
9
+ # Through {Table::Column}, it also provides many configurable
10
+ # column 'types', such as currency and float, to ease the pain of formatting
11
+ # the table on a consistent manner.
12
+ #
13
+ # Above that, it also provides a very useful feature that Prawn misses:
14
+ # column spanning (although currently only on footers).
15
+ #
16
+ # == Table footers
17
+ #
18
+ # Table footers are special rows that are often used to make a summary of
19
+ # the table data.
20
+ #
21
+ # The main differences between a footer row and a ordinary row are:
22
+ # [Column spanning]
23
+ # Normal row's cells can't span across multiple columns,
24
+ # while a footer cell can.
25
+ # [Calculation]
26
+ # In a normal row, the cells' values are automatically
27
+ # calculated using the provided method, while in a footer
28
+ # row the displayed value should be directly passed.
29
+ # [Scope]
30
+ # A footer row acts in the scope of the whole collection, while
31
+ # a normal row represents a single object.
32
+ # [Format]
33
+ # Footer rows can be formatted in a differente manner, through
34
+ # the use of the +table_footer_*+ properties on {Configuration}
5
35
  class Table
6
36
  attr_accessor *Configuration.table_properties_names
7
37
 
8
- def initialize
38
+ def initialize(document)
9
39
  Configuration.table_properties_names.each do |property|
10
40
  send("#{property}=", Configuration["table_#{property}"])
11
41
  end
42
+ @document = document
12
43
  end
13
44
 
14
- def columns_hash
15
- columns_hash = ActiveSupport::OrderedHash.new
16
- columns.inject(columns_hash) do |hash, column|
17
- hash[column.header] = column.to_proc
18
- hash
19
- end
20
- end
21
-
22
- def columns_headers
23
- columns.map(&:header)
24
- end
25
-
45
+ # The collection of objects to be represented by the table.
46
+ # @return [Array]
47
+ # @raise [ArgumentError] If the passed value isn't an Array
48
+ attr_accessor :collection
26
49
  def collection
27
50
  @collection ||= Array.new
28
51
  end
@@ -32,13 +55,48 @@ module TnPDF
32
55
  @collection = collection
33
56
  end
34
57
 
58
+ # The columns already set on this table. Despite of being first provided
59
+ # as Arrays, the members of this collection are instances of {Column}.
60
+ # @return [Array]
61
+ attr_reader :columns
62
+ def columns(type = :all)
63
+ @columns ||= []
64
+ if type == :all
65
+ @columns
66
+ else
67
+ @columns.select { |c| c.column_width_type == type }
68
+ end
69
+ end
70
+
71
+ # Adds a column to the table. The argument should be a {Column}, or an
72
+ # argument that {Column#initialize} accepts.
73
+ # @param [Column] (see Column#initialize)
35
74
  def add_column(column)
36
75
  unless column.kind_of? Column
37
76
  column = Column.new(column)
38
77
  end
78
+ column.index = columns.count
79
+ column.max_width = document_width
39
80
  columns << column
40
81
  end
41
82
 
83
+ def remove_column(column)
84
+ if column.kind_of? Column
85
+ columns.delete(column)
86
+ elsif column.kind_of? Fixnum
87
+ columns.delete_at(column)
88
+ else
89
+ raise ArgumentError, "Unrecognized argument '#{column.inspect}'"
90
+ end
91
+ end
92
+
93
+ def reset_columns
94
+ @columns = []
95
+ end
96
+
97
+ # The already computed rows of the table. Needs {#columns} and
98
+ # {#collection} to be already set to return something meaningful.
99
+ # @return [Array] An array containing the rows to be rendered.
42
100
  def rows
43
101
  collection.map do |object|
44
102
  columns.map do |column|
@@ -47,45 +105,239 @@ module TnPDF
47
105
  end
48
106
  end
49
107
 
50
- def render(document, max_height)
51
- table = document.make_table([columns_headers]+rows) do |table|
52
- table.header = self.multipage_headers
53
- table.cells.borders = [] unless self.borders
54
- table.row_colors = [self.odd_row_color, self.even_row_color]
55
- table.row(0).background_color = self.header_color
108
+ def render(max_height)
109
+ x_pos = x_pos_on(document, document_width)
110
+
111
+ initialize_generated_widths
112
+ total_width = self.column_widths.sum
56
113
 
57
- columns.each_with_index do |column, index|
58
- style = column.style.reject { |k, v| [:format, :decimal].include? k }
59
- table.columns(index).style(style)
60
- end
61
- end
62
- x_pos = x_pos_on(document, table.width)
63
114
  document.bounding_box([x_pos, document.cursor],
64
- :width => table.width,
115
+ :width => total_width,
65
116
  :height => max_height) do
66
- table.draw
117
+
118
+ table_data = [[header_table]]
119
+ table_data += minitables
120
+ table_data += footer_tables
121
+
122
+ document.text *([text_before].flatten)
123
+ document.font_size self.font_size
124
+
125
+ document.table(table_data, :column_widths => [total_width],
126
+ :width => total_width) do |table|
127
+
128
+ table.header = self.multipage_headers
129
+ stylize_table(table)
130
+ end
131
+
132
+ document.text *([text_after].flatten)
67
133
  end
68
134
  end
69
135
 
70
- def columns
71
- @columns ||= []
136
+ # Adds a footer row to the table.
137
+ #
138
+ # The argument to this method should be an Array (or a block that returns
139
+ # an Array) in which each member is a cell in the format:
140
+ # [content, colspan, style]
141
+ # where:
142
+ # [*content*]
143
+ # Is the content of the cell. May be a label, a sum of some values
144
+ # from the collection etc.
145
+ # [*colspan*]
146
+ # A number representing how many columns of the table this cell is going
147
+ # to span across. Be warned that the sum of all the colspans on a given
148
+ # footer row should always match the number of columns in the table, or
149
+ # an exception will be raised.
150
+ # [*style*]
151
+ # The formatting style of the cell. Refer to {Column#style Column#style}
152
+ # for details.
153
+ # @example
154
+ # # On a table that has 2 columns
155
+ # table.add_footer [
156
+ # ["Total", 1, :text],
157
+ # [12345, 1, :number]
158
+ # ]
159
+ # @example
160
+ # # On a table that has 3 columns
161
+ # table.add_footer do |collection|
162
+ # calculation = collection.map(&:value).sum
163
+ # [
164
+ # ["Calculation", 1, :text],
165
+ # [calculation, 2, :number]
166
+ # ]
167
+ # end
168
+ def add_footer(row=nil, &block)
169
+ unless block_given? or row.kind_of? Array
170
+ raise ArgumentError, "No block or array was passed"
171
+ end
172
+
173
+ row = block.call(collection) if block_given?
174
+
175
+ # [content, colspan, style]
176
+ row = row.map do |field|
177
+ field[2] ||= :text
178
+ content = Column.format_value(field[0],
179
+ Column.style_for(field[2]))
180
+
181
+ OpenStruct.new(:content => content,
182
+ :colspan => field[1],
183
+ :style => Column.prawn_style_for(field[2]) )
184
+ end
185
+
186
+ row.inject(0) do |first_column, field|
187
+ final_column = first_column+field.colspan-1
188
+ field.colspan_range = (first_column..final_column)
189
+ final_column+1
190
+ end
191
+
192
+ total_colspan = row.map(&:colspan).inject(:+)
193
+ unless total_colspan == columns.length
194
+ raise ArgumentError,
195
+ "Total colspan value '#{total_colspan}' differs from the "+
196
+ "table's columns number '#{columns.length}'"
197
+ end
198
+
199
+ footer_rows << row
200
+ end
201
+
202
+ def row_color(row_number)
203
+ row_number % 2 == 0 ?
204
+ self.even_row_color:
205
+ self.odd_row_color
206
+ end
207
+
208
+ def column_widths(type = :all)
209
+ selected_columns = columns(type)
210
+ selected_columns.sort_by(&:index).map(&:width)
72
211
  end
73
212
 
74
213
  private
75
214
 
215
+ def columns_headers
216
+ columns.map(&:header)
217
+ end
218
+
76
219
  def x_pos_on(document, table_width)
77
220
  case align
78
- when :left
79
- 0
80
- when :center
81
- (document.bounds.right - table_width)/2.0
82
- when :right
83
- document.bounds.right - table_width
84
- else
85
- 0
221
+ when :left
222
+ 0
223
+ when :center
224
+ (document.bounds.right - table_width)/2.0
225
+ when :right
226
+ document.bounds.right - table_width
227
+ else
228
+ 0
229
+ end
230
+ end
231
+
232
+ def document
233
+ @document
234
+ end
235
+
236
+ def prawn_table
237
+ if @prev_headers != columns_headers or
238
+ @prev_rows != rows
239
+
240
+ @prawn_table = nil
241
+ @prev_headers = columns_headers
242
+ @prev_rows = rows
243
+ end
244
+
245
+ @prawn_table ||= begin
246
+ document.make_table([columns_headers]+rows) do |table|
247
+ document.font_size = self.font_size
248
+ stylize_table(table)
249
+ end
86
250
  end
87
251
  end
88
252
 
253
+ def minitables
254
+ row_number = 0 # I hate this as much as you do
255
+ rows.map do |row|
256
+ minitable = document.make_table([row],
257
+ :column_widths => column_widths) do |table|
258
+ columns.each_with_index do |column, index|
259
+ table.columns(index).style(column.prawn_style)
260
+ end
261
+ table.rows(0..-1).size = self.font_size
262
+ row_number += 1
263
+ stylize_table(table)
264
+ table.cells.background_color = self.row_color(row_number)
265
+ end
266
+ [minitable]
267
+ end
268
+ end
269
+
270
+ def header_table
271
+ document.make_table([columns_headers],
272
+ :column_widths => column_widths) do |table|
273
+ header_row = table.row(0)
274
+ header_row.background_color = self.header_color
275
+ header_row.font_style = self.header_font_style
276
+ header_row.size = self.header_font_size
277
+ header_row.font = self.header_font
278
+ header_row.align = :center
279
+
280
+ stylize_table(table)
281
+ end
282
+ end
283
+
284
+ def stylize_table(table)
285
+ table.cells.borders = borders || []
286
+ end
287
+
288
+ def footer_rows
289
+ @footer_rows ||= []
290
+ end
291
+
292
+ def footer_tables
293
+ footer_rows.map do |row|
294
+ [footer_table_for(row)]
295
+ end
296
+ end
297
+
298
+ def footer_table_for(row)
299
+ row_array = [row.map(&:content)]
300
+ footer_column_widths = row.map do |field|
301
+ column_widths[field.colspan_range].inject(:+)
302
+ end
303
+
304
+ document.make_table(row_array,
305
+ :column_widths => footer_column_widths) do |table|
306
+
307
+ footer_row = table.row(0)
308
+ footer_row.background_color = self.footer_color
309
+ footer_row.font_style = self.footer_font_style
310
+ footer_row.size = self.footer_font_size
311
+ footer_row.font = self.footer_font
312
+
313
+ row.each_with_index do |field, index|
314
+ table.columns(index).style(field.style)
315
+ end
316
+ stylize_table(table)
317
+ end
318
+ end
319
+
320
+ def initialize_generated_widths
321
+ columns(:generated).each do |column|
322
+ index = column.index
323
+ column.width = prawn_table.columns(index).width
324
+ end
325
+ generated_widths_sum = column_widths(:generated).sum
326
+
327
+ fixed_widths_sum = column_widths(:fixed).sum
328
+ percentage_widths_sum = column_widths(:percentage).sum
329
+ remaining_space = document_width - percentage_widths_sum - fixed_widths_sum
330
+
331
+ columns(:generated).each do |column|
332
+ width = column.width
333
+ column.width = (width/generated_widths_sum)*remaining_space
334
+ column.column_width_type = :fixed
335
+ end
336
+ end
337
+
338
+ def document_width
339
+ document.bounds.width
340
+ end
89
341
  end
90
342
 
91
343
  end
@@ -1,49 +1,130 @@
1
1
  module TnPDF
2
2
  class Table
3
-
3
+ # Represents a column of a table. Typically not used directly, but through
4
+ # {Table#add_column Table#add_column} and friends.
4
5
  class Column
5
- attr_reader :header, :proc, :collection, :style
6
+ # Is a String that contains the column header
7
+ # @return [String]
8
+ attr_reader :header
9
+
10
+ # Is an object that responds to to_proc, typically a Symbol or a Proc.
11
+ # It represents the procedure used to extract the information from the
12
+ # object.
13
+ # @example
14
+ # column.proc = :full_name
15
+ # @example
16
+ # myProc = Proc.new do |person|
17
+ # "#{person.id} - #{person.name}"
18
+ # end
19
+ # column.proc = myProc
20
+ # @return [#to_proc]
21
+ attr_reader :proc
22
+
23
+ # Defines how the formatting of the result will occur. Typically used
24
+ # for formatting currencies, numbers etc, but can be anything defined
25
+ # on {Configuration}, the {TnPDF::Configuration.load_from YAML Configuration file}
26
+ # or a Hash that contains (at least) the :format key.
27
+ # It defaults to the :text style.
28
+ # @example
29
+ # column.style = :currency
30
+ # @example
31
+ # column.style = { :format => "%.2f",
32
+ # :decimal => ",",
33
+ # :align => :right }
34
+ # @return [Symbol, Hash]
35
+ attr_reader :style
36
+
37
+ # Defines the (visual) width of the column. May be defined in PDF
38
+ # points (1/72 inch), a String "in" the cm or mm units, or a String
39
+ # representing a percentage.
40
+ #
41
+ # It is important to note that, in the case of a percentage-based
42
+ # column, it represents a percentage of the *page* on which the table
43
+ # will be rendered, not of the table.
44
+ # @example
45
+ # width = 1234.45
46
+ # @example
47
+ # width = "1.5cm"
48
+ # @example
49
+ # width = "14mm"
50
+ # @example
51
+ # width = "20%"
52
+ # @return [Double, String]
53
+ attr_accessor :width
6
54
 
7
55
  alias_method :to_proc, :proc
56
+
57
+ attr_accessor :column_width_type
58
+ # Creates a new Column
59
+ #
60
+ # The parameter has to be an Array in the form:
61
+ # [header, procedure, style, width]
62
+ # where:
63
+ # [{#header header} (required)]
64
+ # {include:#header}
65
+ # [{#proc procedure} (required)]
66
+ # {include:#proc}
67
+ # [{#style style} (optional)]
68
+ # {include:#style}
69
+ # [{#width width} (optional)]
70
+ # {include:#width}
71
+ # @example
72
+ # Column.new [ "Full name", :full_name ]
73
+ # @example
74
+ # sum = Proc.new { |obj| obj.value_a + obj.value_b }
75
+ # Column.new [ "Sum", sum, :number, "15%" ]
8
76
  def initialize(arguments)
9
77
  raise ArgumentError unless valid_column_args?(arguments)
10
78
  @header = arguments[0].to_s
11
79
  @proc = arguments[1].to_proc
12
- @style = style_for(arguments[2])
13
- end
14
-
15
- def values_for(collection)
16
- collection.map do |object|
17
- value_for(object)
80
+ @style = Column.style_for(arguments[2])
81
+ @width = Configuration.perform_conversions arguments[3]
82
+ if @width.nil?
83
+ column_width_type = :generated
18
84
  end
19
85
  end
20
86
 
21
87
  def value_for(object)
22
88
  value = @proc.call(object)
23
- method = if value.respond_to?(:strftime)
24
- value.method(:strftime)
25
- elsif value.respond_to?(:sprintf)
26
- value.method(:sprintf)
27
- else
28
- method(:sprintf)
29
- end
30
- string = method.arity == 1 ?
31
- method.call(style[:format]) :
32
- method.call(style[:format], value)
89
+ Column.format_value(value, style)
90
+ end
33
91
 
34
- string.gsub!(".", style[:decimal]) if style[:decimal]
35
- return string
36
- rescue TypeError
37
- puts "WARNING: Bad format '#{style[:format]}' for value '#{value}'"
38
- return value.to_s
92
+ def prawn_style
93
+ style.reject { |k, v| [:format, :decimal].include? k }
94
+ end
95
+
96
+ def width
97
+ if column_width_type == :percentage
98
+ unless max_width
99
+ raise ArgumentError, "Maximum width should be set for percentage-based widths!"
100
+ end
101
+ match = @width.scan(/(\d+\.?\d*)%/)
102
+
103
+ number = match[0][0].to_f/100.0
104
+ number*max_width
105
+ else
106
+ @width
107
+ end
108
+ end
109
+
110
+ def column_width_type
111
+ @column_width_type ||=
112
+ if @width.kind_of? String
113
+ (@width =~ /(\d+\.?\d*)%/) ? :percentage : :fixed
114
+ elsif @width.kind_of? Numeric
115
+ :fixed
116
+ else
117
+ :generated
118
+ end
39
119
  end
40
120
 
121
+ attr_accessor :index, :max_width
41
122
  private
42
123
 
43
124
  def valid_column_args?(column_args)
44
125
  validity = true
45
126
  validity &= column_args.kind_of? Array
46
- validity &= column_args.count == 2 || column_args.count == 3
127
+ validity &= (2..4).include? column_args.count
47
128
  validity &= column_args[0].respond_to?(:to_s)
48
129
  validity &= column_args[1].respond_to?(:to_proc)
49
130
  rescue NoMethodError
@@ -52,7 +133,7 @@ module TnPDF
52
133
  return validity
53
134
  end
54
135
 
55
- def style_for(type)
136
+ def self.style_for(type)
56
137
  if type.nil?
57
138
  {:format => "%s"}
58
139
  elsif type.kind_of? Symbol
@@ -61,6 +142,34 @@ module TnPDF
61
142
  type
62
143
  end
63
144
  end
145
+
146
+ def self.prawn_style_for(type)
147
+ style_for(type).reject { |k, v| [:format, :decimal].include? k }
148
+ end
149
+
150
+ def self.format_value(value, style)
151
+ formatting_method = formatting_method_for(value)
152
+ string = formatting_method.call(style[:format])
153
+
154
+ string.gsub!(".", style[:decimal]) if style[:decimal]
155
+ return string
156
+ rescue TypeError, ArgumentError
157
+ puts "WARNING: Bad format '#{style[:format]}' for value '#{value.inspect}'"
158
+ return value.to_s
159
+ end
160
+
161
+ def self.formatting_method_for(value)
162
+ if value.respond_to?(:strftime)
163
+ lambda { |format| value.strftime(format) }
164
+ elsif value.respond_to?(:sprintf)
165
+ lambda { |format| value.sprintf(format) }
166
+ elsif value.nil?
167
+ lambda { |_| "" }
168
+ else
169
+ lambda { |format| sprintf(format, value) }
170
+ end
171
+ end
172
+ private_class_method :formatting_method_for
64
173
  end
65
174
 
66
175
  end
@@ -1,3 +1,3 @@
1
1
  module TnPDF
2
- VERSION = "0.0.2"
2
+ VERSION = "0.0.8"
3
3
  end
data/lib/tn_pdf.rb CHANGED
@@ -1,7 +1,10 @@
1
1
  require 'rubygems'
2
- require 'active_support' # Because of to_options!
3
2
  require 'prawn'
4
3
  require 'prawn/measurement_extensions'
4
+ require 'ostruct'
5
+ require 'backports'
6
+
7
+ load 'prawn/table_fix.rb'
5
8
 
6
9
  require 'tn_pdf/configuration'
7
10
  require 'tn_pdf/report'
@@ -19,17 +19,17 @@ module TnPDF
19
19
 
20
20
  it "accepts extra text options to prawn by passing hashes" do
21
21
  document = Prawn::Document.new
22
- box = Box.new( :text => {:text => "text", :font => "DejaVu Sans"} )
22
+ box = Box.new( :text => {:text => "text", :font => "Courier"} )
23
23
 
24
- document.should_receive(:text).with("text", hash_including(:font => "DejaVu Sans") )
24
+ document.should_receive(:text).with("text", hash_including(:font => "Courier") )
25
25
  box.render(document, [0,0], 100)
26
26
  end
27
27
 
28
28
  it "accepts extra image options to prawn by passing hashes" do
29
29
  document = Prawn::Document.new
30
- box = Box.new( :image => {:path => "image.jpg", :width => 1.cm } )
30
+ box = Box.new( :image => {:file => "./image.jpg", :width => 1.cm } )
31
31
 
32
- document.should_receive(:image).with("image.jpg", hash_including(:width => 1.cm) )
32
+ document.should_receive(:image).with("./image.jpg", hash_including(:width => 1.cm) )
33
33
  box.render(document, [0,0], 100)
34
34
  end
35
35
  end
@@ -5,7 +5,10 @@ module TnPDF
5
5
  const_set("Column",EmptyClass) unless const_defined?("Column")
6
6
  end
7
7
 
8
+
8
9
  describe Table do
10
+ let(:subject) { Table.new(stub('Document')) }
11
+
9
12
  describe "#columns_hash" do
10
13
  it "is a kind of Hash" do
11
14
  subject.columns_hash.should be_kind_of(Hash)
@@ -71,30 +74,31 @@ module TnPDF
71
74
 
72
75
  describe "#rows" do
73
76
  it "returns the values of the objects for each column" do
74
- subject = Table.new # Just to be explicit
77
+ subject = Table.new(stub('Document')) # Just to be explicit
75
78
  subject.add_column( ["String", :to_s] )
76
79
  subject.add_column( ["Integer", :to_i] )
77
80
  subject.add_column( ["Doubled", Proc.new { |x| x*2 } ] )
78
81
 
79
82
  subject.collection = [1, 2, 3]
80
- subject.rows.should == [ ["1", 1, 2],
81
- ["2", 2, 4],
82
- ["3", 3, 6] ]
83
+ subject.rows.should == [ ["1", "1", "2"],
84
+ ["2", "2", "4"],
85
+ ["3", "3", "6"] ]
83
86
  end
84
87
  end
85
88
 
86
89
  describe "#render" do
87
- let(:document) do
88
- mock("Prawn::Document").as_null_object
89
- end
90
-
91
90
  let(:table) do
92
91
  mock("Prawn::Table").as_null_object
93
92
  end
94
93
 
94
+ let(:document) do
95
+ mock("Prawn::Document").as_null_object
96
+ end
97
+
95
98
  it "instantiates a Prawn::Table instance" do
96
99
  document.should_receive(:make_table).and_return(table)
97
- subject.render(document, 0)
100
+ subject = Table.new(document)
101
+ subject.render(0)
98
102
  end
99
103
  end
100
104
  end
data/tn_pdf.gemspec CHANGED
@@ -13,7 +13,7 @@ Gem::Specification.new do |s|
13
13
  s.rubyforge_project = "tn_pdf"
14
14
 
15
15
  s.add_dependency('prawn', '~> 0.11.1')
16
- s.add_dependency('activesupport')
16
+ s.add_dependency('backports')
17
17
 
18
18
  s.add_development_dependency('rspec', '~> 2.6.0')
19
19