fat_table 0.2.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,81 @@
1
+ module FatTable
2
+ class << self;
3
+ # The +DBI+ database handle returned by the last successful call to
4
+ # FatTable.set_db.
5
+ attr_accessor :handle
6
+ end
7
+
8
+ # This method must be called before calling FatTable.from_sql or
9
+ # FatTable::Table.from_sql in order to specify the database to use. All of
10
+ # the keyword parameters have a default except +database:+, which must contain
11
+ # the name of the database to query.
12
+ #
13
+ # +driver+::
14
+ # One of 'Pg' (for Postgresql), 'Mysql' (for Mysql), or 'SQLite3' (for
15
+ # SQLite3) to specify the +DBI+ driver to use. You may have to install the
16
+ # driver to make this work. By default use 'Pg'.
17
+ #
18
+ # +database+::
19
+ # The name of the database to access. There is no default for this.
20
+ #
21
+ # +user+::
22
+ # The user name to use for accessing the database. It defaults to nil,
23
+ # which may be interpreted as a default user by the DBI driver being used.
24
+ #
25
+ # +password+::
26
+ # The password to use for accessing the database. It defaults to nil,
27
+ # which may be interpreted as a default password by the DBI driver being used.
28
+ #
29
+ # +host+::
30
+ # The name of the host on which to look for the database connection,
31
+ # defaulting to 'localhost'.
32
+ #
33
+ # +port+::
34
+ # The port number as a string or integer on which to access the database on
35
+ # the given host. Defaults to '5432'. Only used if host is not 'localhost'.
36
+ #
37
+ # +socket+::
38
+ # The socket to use to access the database if the host is 'localhost'.
39
+ # Defaults to the standard socket for the Pg driver, '/tmp/.s.PGSQL.5432'.
40
+ #
41
+ # If successful the database handle for DBI is return.
42
+ # Once called successfully, this establishes the database handle to use for
43
+ # all subsequent calls to FatTable.from_sql or FatTable::Table.from_sql. You
44
+ # can then access the handle if needed with FatTable.db.
45
+ def self.set_db(driver: 'Pg',
46
+ database:,
47
+ user: nil,
48
+ password: nil,
49
+ host: 'localhost',
50
+ port: '5432',
51
+ socket: '/tmp/.s.PGSQL.5432')
52
+ raise UserError, 'must supply database name to set_db' unless database
53
+
54
+ valid_drivers = ['Pg', 'Mysql', 'SQLite3']
55
+ unless valid_drivers.include?(driver)
56
+ raise UserError, "set_db driver must be one of #{valid_drivers.join(' or ')}"
57
+ end
58
+ # In case port is given as an integer
59
+ port = port.to_s if port
60
+
61
+ # Set the dsn for DBI
62
+ dsn =
63
+ if host == 'localhost'
64
+ "DBI:Pg:database=#{database};host=#{host};socket=#{socket}"
65
+ else
66
+ "DBI:Pg:database=#{database};host=#{host};port=#{port}"
67
+ end
68
+ begin
69
+ self.handle = ::DBI.connect(dsn, user, password)
70
+ rescue DBI::OperationalError => ex
71
+ raise TransientError, "#{dsn}: #{ex}"
72
+ end
73
+ handle
74
+ end
75
+
76
+ # Return the +DBI+ database handle as returned by the last call to
77
+ # FatTable.set_db.
78
+ def self.db
79
+ handle
80
+ end
81
+ end
@@ -0,0 +1,13 @@
1
+ module FatTable
2
+ # Raised when the caller of the code made an error that the caller can
3
+ # correct.
4
+ class UserError < StandardError; end
5
+
6
+ # Raised when the programmer made an error that the caller of the code
7
+ # cannot correct.
8
+ class LogicError < StandardError; end
9
+
10
+ # Raised when an external resource is not available due to caller or
11
+ # programmer error or some failure of the external resource to be available.
12
+ class TransientError < StandardError; end
13
+ end
@@ -0,0 +1,55 @@
1
+ module FatTable
2
+ # The Evaluator class provides a class for evaluating Ruby expressions based
3
+ # on variable settings provided at runtime. If the same Evaluator object is
4
+ # used for several successive calls, it can maintain state between calls with
5
+ # instance variables. The call to Evaluator.new can be given a hash of
6
+ # instance variable names and values that will be maintained across all calls
7
+ # to the #evaluate method. In addition, on each evaluate call, a set of
8
+ # /local/ variables can be supplied to provide variables that exist only for
9
+ # the duration of that evaluate call. An optional before and after string can
10
+ # be given to the constructor that will evaluate the given expression before
11
+ # and, respectively, after each call to #evaluate. This provides a way to
12
+ # update values of instance variables for use in subsequent calls to
13
+ # #evaluate.
14
+ class Evaluator
15
+ # Return a new Evaluator object in which the Hash +vars+ defines the
16
+ # bindings for instance variables to be available and maintained across all
17
+ # subsequent calls to Evaluator.evaluate. The strings +before+ and +after+
18
+ # are string expressions that will be evaluated before and after each
19
+ # subsequent call to Evaluator.evaluate.
20
+ def initialize(vars: {}, before: nil, after: nil)
21
+ @before = before
22
+ @after = after
23
+ set_instance_vars(vars)
24
+ end
25
+
26
+ # Return the result of evaluating +expr+ as a Ruby expression in which the
27
+ # instance variables set in Evaluator.new and any local variables set in the
28
+ # Hash parameter +vars+ are available to the expression. Call any +before+
29
+ # hook defined in Evaluator.new before evaluating +expr+ and any +after+
30
+ # hook defined in Evaluator.new after evaluating +expr+.
31
+ def evaluate(expr = '', vars: {})
32
+ bdg = binding
33
+ set_local_vars(vars, bdg)
34
+ eval(@before, bdg) if @before
35
+ result = eval(expr, bdg)
36
+ eval(@after, bdg) if @after
37
+ result
38
+ end
39
+
40
+ private
41
+
42
+ def set_instance_vars(vars = {})
43
+ vars.each_pair do |name, val|
44
+ name = "@#{name}" unless name.to_s.start_with?('@')
45
+ instance_variable_set(name, val)
46
+ end
47
+ end
48
+
49
+ def set_local_vars(vars = {}, bnd)
50
+ vars.each_pair do |name, val|
51
+ bnd.local_variable_set(name, val)
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,7 @@
1
+ require 'fat_table/formatters/formatter'
2
+ require 'fat_table/formatters/aoa_formatter'
3
+ require 'fat_table/formatters/aoh_formatter'
4
+ require 'fat_table/formatters/org_formatter'
5
+ require 'fat_table/formatters/text_formatter'
6
+ require 'fat_table/formatters/term_formatter'
7
+ require 'fat_table/formatters/latex_formatter'
@@ -0,0 +1,91 @@
1
+ module FatTable
2
+ # A subclass of Formatter for rendering the table as a Ruby Array of Arrays.
3
+ # Each cell is an element of the inner Array is formatted as a string in
4
+ # accordance with the formatting directives. All footers are included as extra
5
+ # Arrays of the output. AoaFormatter supports no +options+
6
+ class AoaFormatter < Formatter
7
+
8
+ private
9
+
10
+ def evaluate?
11
+ true
12
+ end
13
+
14
+ def pre_table
15
+ '['
16
+ end
17
+
18
+ def post_table
19
+ ']'
20
+ end
21
+
22
+ def pre_header(_widths)
23
+ ''
24
+ end
25
+
26
+ def post_header(_widths)
27
+ ''
28
+ end
29
+
30
+ def pre_row
31
+ '['
32
+ end
33
+
34
+ def pre_cell(_h)
35
+ "'"
36
+ end
37
+
38
+ # Because the cell, after conversion to a single-quoted string will be
39
+ # eval'ed, we need to escape any single-quotes (') that appear in the
40
+ # string.
41
+ def quote_cell(v)
42
+ if v =~ /'/
43
+ # Use a negative look-behind to only quote single-quotes that are not
44
+ # already preceded by a backslash
45
+ v.gsub(/(?<!\\)'/, "'" => "\\'")
46
+ else
47
+ v
48
+ end
49
+ end
50
+
51
+ def post_cell
52
+ "'"
53
+ end
54
+
55
+ def inter_cell
56
+ ','
57
+ end
58
+
59
+ def post_row
60
+ "],\n"
61
+ end
62
+
63
+ def hline(_widths)
64
+ "nil,\n"
65
+ end
66
+
67
+ def pre_group
68
+ ''
69
+ end
70
+
71
+ def post_group
72
+ ''
73
+ end
74
+
75
+ def pre_gfoot
76
+ ''
77
+ end
78
+
79
+ def post_gfoot
80
+ ''
81
+ end
82
+
83
+ def pre_foot
84
+ ''
85
+ end
86
+
87
+ def post_foot
88
+ ''
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,91 @@
1
+ module FatTable
2
+ # A subclass of Formatter for rendering the table as a Ruby Array of Hashes.
3
+ # Each row of the Array is a Hash representing one row of the table with the
4
+ # keys being the symbolic form of the headers. Each cell is a value in a row
5
+ # Hash formatted as a string in accordance with the formatting directives. All
6
+ # footers are included as extra Hashes of the output. AoaFormatter supports no
7
+ # +options+
8
+ class AohFormatter < Formatter
9
+
10
+ private
11
+
12
+ def evaluate?
13
+ true
14
+ end
15
+
16
+ def pre_table
17
+ '['
18
+ end
19
+
20
+ def post_table
21
+ ']'
22
+ end
23
+
24
+ # We include no row for the header because the keys of each hash serve as
25
+ # the headers.
26
+ def include_header_row?
27
+ false
28
+ end
29
+
30
+ def pre_row
31
+ '{'
32
+ end
33
+
34
+ def pre_cell(h)
35
+ ":#{h.as_sym} => '"
36
+ end
37
+
38
+ # Because the cell, after conversion to a single-quoted string will be
39
+ # eval'ed, we need to escape any single-quotes (') that appear in the
40
+ # string.
41
+ def quote_cell(v)
42
+ if v =~ /'/
43
+ # Use a negative look-behind to only quote single-quotes that are not
44
+ # already preceded by a backslash
45
+ v.gsub(/(?<!\\)'/, "'" => "\\'")
46
+ else
47
+ v
48
+ end
49
+ end
50
+
51
+ def post_cell
52
+ "'"
53
+ end
54
+
55
+ def inter_cell
56
+ ','
57
+ end
58
+
59
+ def post_row
60
+ "},\n"
61
+ end
62
+
63
+ def hline(_widths)
64
+ "nil,\n"
65
+ end
66
+
67
+ def pre_group
68
+ ''
69
+ end
70
+
71
+ def post_group
72
+ ''
73
+ end
74
+
75
+ def pre_gfoot
76
+ ''
77
+ end
78
+
79
+ def post_gfoot
80
+ ''
81
+ end
82
+
83
+ def pre_foot
84
+ ''
85
+ end
86
+
87
+ def post_foot
88
+ ''
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,1248 @@
1
+ module FatTable
2
+ # A Formatter is for use in Table output routines, and provides methods for
3
+ # adding group and table footers to the output and instructions for how the
4
+ # table's cells ought to be formatted. The goal is to make subclasses of this
5
+ # class handle different output targets, such as aoa for org tables, ANSI
6
+ # terminals, LaTeX, plain text, org mode table text, and so forth. Many of
7
+ # the formatting options, such as color, will be no-ops for some output
8
+ # targets, such as text, but will be valid nonetheless. Thus, the Formatter
9
+ # subclass should provide the best implementation for each formatting request
10
+ # available for the target. This base class will consist largely of methods
11
+ # that format output as pipe-separated values, but implementations provided
12
+ # by subclasses will override these for different output targets.
13
+ class Formatter
14
+ # Valid locations in a Table as an array of symbols.
15
+ LOCATIONS = [:header, :body, :bfirst, :gfirst, :gfooter, :footer].freeze
16
+
17
+ # The table that is the subject of the Formatter.
18
+ attr_reader :table
19
+
20
+ # Options given to the Formatter constructor that allow variants for
21
+ # specific Formatters.
22
+ attr_reader :options
23
+
24
+ # A Hash of Hashes with the outer Hash keyed on location. The value for the
25
+ # outer Hash is another Hash keyed on column names. The values of the inner
26
+ # Hash are OpenStruct objects that contain the formatting instructions for
27
+ # the location and column. For example, +format_at[:body][:shares].commas+
28
+ # is set either true or false depending on whether the +:shares+ column in
29
+ # the table body is to have grouping commas inserted in the output.
30
+ attr_reader :format_at
31
+
32
+ # A Hash of the table-wide footers to be added to the output. The key is a
33
+ # string that is to serve as the label for the footer and inserted in the
34
+ # first column of the footer if that column is otherwise not populated with
35
+ # footer content. The value is Hash in which the keys are column symbols and
36
+ # the values are symbols for the aggregate method to be applied to the
37
+ # column to provide a value in the footer for that column. Thus,
38
+ # +footers['Total'][:shares]+ might be set to +:sum+ to indicate that the
39
+ # +:shares+ column is to be summed in the footer labeled 'Total'.
40
+ attr_reader :footers
41
+
42
+ # A Hash of the group footers to be added to the output. The key is a string
43
+ # that is to serve as the label for the footer and inserted in the first
44
+ # column of the footer if that column is otherwise not populated with group
45
+ # footer content. The value is Hash in which the keys are column symbols and
46
+ # the values are symbols for the aggregate method to be applied to the
47
+ # group's column to provide a value in the group footer for that column.
48
+ # Thus, +gfooters['Average'][:shares]+ might be set to +:avg+ to indicate that
49
+ # the +:shares+ column is to be averaged in the group footer labeled 'Average'.
50
+ attr_reader :gfooters
51
+
52
+ class_attribute :default_format
53
+ self.default_format = {
54
+ nil_text: '',
55
+ case: :none,
56
+ alignment: :left,
57
+ bold: false,
58
+ italic: false,
59
+ color: 'none',
60
+ bgcolor: 'none',
61
+ hms: false,
62
+ pre_digits: 0,
63
+ post_digits: -1,
64
+ commas: false,
65
+ currency: false,
66
+ datetime_fmt: '%F %H:%M:%S',
67
+ date_fmt: '%F',
68
+ true_text: 'T',
69
+ false_text: 'F',
70
+ true_color: 'none',
71
+ true_bgcolor: 'none',
72
+ false_color: 'none',
73
+ false_bgcolor: 'none',
74
+ underline: false,
75
+ blink: false
76
+ }
77
+
78
+ class_attribute :valid_colors
79
+ self.valid_colors = ['none']
80
+
81
+ # :category: Constructors
82
+
83
+ # Return a new Formatter for the given +table+ which must be of the class
84
+ # FatTable::Table. The +options+ hash can specify variants for the output
85
+ # for specific subclasses of Formatter. This base class outputs the +table+
86
+ # as a string in the pipe-separated form, which is much like CSV except that
87
+ # it uses the ASCII pipe symbol +|+ to separate values rather than the
88
+ # comma, and therefore does not bother to quote strings since it assumes
89
+ # they will not contain any pipes. A new Formatter provides default
90
+ # formatting for all the cells in the table. If you give a block, the new
91
+ # Formatter is yielded to the block so that methods for formatting and
92
+ # adding footers can be called on it.
93
+ def initialize(table = Table.new, **options)
94
+ unless table && table.is_a?(Table)
95
+ raise UserError, 'must initialize Formatter with a Table'
96
+ end
97
+ @table = table
98
+ @options = options
99
+ @footers = {}
100
+ @gfooters = {}
101
+ # Formatting instructions for various "locations" within the Table, as
102
+ # a hash of hashes. The outer hash is keyed on the location, and each
103
+ # inner hash is keyed on either a column sym or a type sym, :string, :numeric,
104
+ # :datetime, :boolean, or :nil. The value of the inner hashes are
105
+ # OpenStruct structs.
106
+ @format_at = {}
107
+ [:header, :bfirst, :gfirst, :body, :footer, :gfooter].each do |loc|
108
+ @format_at[loc] = {}
109
+ table.headers.each do |h|
110
+ fmt_hash = self.class.default_format
111
+ fmt_hash[:_h] = h
112
+ fmt_hash[:_location] = loc
113
+ format_at[loc][h] = OpenStruct.new(fmt_hash)
114
+ end
115
+ end
116
+ yield self if block_given?
117
+ end
118
+
119
+ # :category: Footers
120
+
121
+ ############################################################################
122
+ # Footer methods
123
+ #
124
+ # A Table may have any number of footers and any number of group footers.
125
+ # Footers are not part of the table's data and never participate in any of
126
+ # the transformation methods on tables. They are never inherited by output
127
+ # tables from input tables in any of the transformation methods.
128
+ #
129
+ # When output, a table footer will appear at the bottom of the table, and a
130
+ # group footer will appear at the bottom of each group.
131
+ #
132
+ # Each footer must have a label, usually a string such as 'Total', to
133
+ # identify the purpose of the footer, and the label must be distinct among
134
+ # all footers of the same type. That is you may have a table footer labeled
135
+ # 'Total' and a group footer labeled 'Total', but you may not have two table
136
+ # footers with that label. If the first column of the table is not included
137
+ # in the footer, the footer's label will be placed there, otherwise, there
138
+ # will be no label output. The footers are accessible with the #footers
139
+ # method, which returns a hash indexed by the label converted to a symbol.
140
+ # The symbol is reconverted to a title-cased string on output.
141
+ #
142
+ # Note that by adding footers or gfooters to the table, you are only stating
143
+ # what footers you want on output of the table. No actual calculation is
144
+ # performed until the table is output.
145
+ #
146
+ # Add a table footer to the table with a label given in the first parameter,
147
+ # defaulting to 'Total'. After the label, you can given any number of
148
+ # headers (as symbols) for columns to be summed, and then any number of hash
149
+ # parameters for columns for with to apply an aggregate other than :sum.
150
+ # For example, these are valid footer definitions.
151
+ #
152
+ # Just sum the shares column with a label of 'Total'
153
+ # tab.footer(:shares)
154
+ #
155
+ # Change the label and sum the :price column as well
156
+ # tab.footer('Grand Total', :shares, :price)
157
+ #
158
+ # Average then show standard deviation of several columns
159
+ # tab.footer.('Average', date: avg, shares: :avg, price: avg)
160
+ # tab.footer.('Sigma', date: dev, shares: :dev, price: :dev)
161
+ #
162
+ # Do some sums and some other aggregates: sum shares, average date and
163
+ # price.
164
+ # tab.footer.('Summary', :shares, date: avg, price: avg)
165
+ def footer(label, *sum_cols, **agg_cols)
166
+ label = label.to_s
167
+ foot = {}
168
+ sum_cols.each do |h|
169
+ unless table.headers.include?(h)
170
+ raise UserError, "No '#{h}' column in table to sum in the footer"
171
+ end
172
+ foot[h] = :sum
173
+ end
174
+ agg_cols.each do |h, agg|
175
+ unless table.headers.include?(h)
176
+ raise UserError, "No '#{h}' column in table to #{agg} in the footer"
177
+ end
178
+ foot[h] = agg
179
+ end
180
+ @footers[label] = foot
181
+ self
182
+ end
183
+
184
+ # :category: Footers
185
+
186
+ # Add a group footer to the table with a label given in the first parameter,
187
+ # defaulting to 'Total'. After the label, you can given any number of
188
+ # headers (as symbols) for columns to be summed, and then any number of hash
189
+ # parameters for columns for with to apply an aggregate other than :sum. For
190
+ # example, these are valid gfooter definitions.
191
+ #
192
+ # Just sum the shares column with a label of 'Total' tab.gfooter(:shares)
193
+ #
194
+ # Change the label and sum the :price column as well tab.gfooter('Total',
195
+ # :shares, :price)
196
+ #
197
+ # Average then show standard deviation of several columns
198
+ # tab.gfooter.('Average', date: avg, shares: :avg, price: avg)
199
+ # tab.gfooter.('Sigma', date: dev, shares: :dev, price: :dev)
200
+ #
201
+ # Do some sums and some other aggregates: sum shares, average date and
202
+ # price. tab.gfooter.('Summary', :shares, date: avg, price: avg)
203
+ def gfooter(label, *sum_cols, **agg_cols)
204
+ label = label.to_s
205
+ foot = {}
206
+ sum_cols.each do |h|
207
+ unless table.headers.include?(h)
208
+ raise UserError, "No '#{h}' column in table to sum in the group footer"
209
+ end
210
+ foot[h] = :sum
211
+ end
212
+ agg_cols.each do |h, agg|
213
+ unless table.headers.include?(h)
214
+ raise UserError, "No '#{h}' column in table to #{agg} in the group footer"
215
+ end
216
+ foot[h] = agg
217
+ end
218
+ @gfooters[label] = foot
219
+ self
220
+ end
221
+
222
+ # :category: Footers
223
+
224
+ # Add table footer to sum the +cols+ given as header symbols.
225
+ def sum_footer(*cols)
226
+ footer('Total', *cols)
227
+ end
228
+
229
+ # :category: Footers
230
+
231
+ # Add group footer to sum the +cols+ given as header symbols.
232
+ def sum_gfooter(*cols)
233
+ gfooter('Group Total', *cols)
234
+ end
235
+
236
+ # :category: Footers
237
+
238
+ # Add table footer to average the +cols+ given as header symbols.
239
+ def avg_footer(*cols)
240
+ hsh = {}
241
+ cols.each do |c|
242
+ hsh[c] = :avg
243
+ end
244
+ footer('Average', hsh)
245
+ end
246
+
247
+ # :category: Footers
248
+
249
+ # Add group footer to average the +cols+ given as header symbols.
250
+ def avg_gfooter(*cols)
251
+ hsh = {}
252
+ cols.each do |c|
253
+ hsh[c] = :avg
254
+ end
255
+ gfooter('Group Average', hsh)
256
+ end
257
+
258
+ # :category: Footers
259
+
260
+ # Add table footer to display the minimum value of the +cols+ given as
261
+ # header symbols.
262
+ def min_footer(*cols)
263
+ hsh = {}
264
+ cols.each do |c|
265
+ hsh[c] = :min
266
+ end
267
+ footer('Minimum', hsh)
268
+ end
269
+
270
+ # :category: Footers
271
+
272
+ # Add group footer to display the minimum value of the +cols+ given as
273
+ # header symbols.
274
+ def min_gfooter(*cols)
275
+ hsh = {}
276
+ cols.each do |c|
277
+ hsh[c] = :min
278
+ end
279
+ gfooter('Group Minimum', hsh)
280
+ end
281
+
282
+ # :category: Footers
283
+
284
+ # Add table footer to display the maximum value of the +cols+ given as
285
+ # header symbols.
286
+ def max_footer(*cols)
287
+ hsh = {}
288
+ cols.each do |c|
289
+ hsh[c] = :max
290
+ end
291
+ footer('Maximum', hsh)
292
+ end
293
+
294
+ # :category: Footers
295
+
296
+ # Add group footer to display the maximum value of the +cols+ given as
297
+ # header symbols.
298
+ def max_gfooter(*cols)
299
+ hsh = {}
300
+ cols.each do |c|
301
+ hsh[c] = :max
302
+ end
303
+ gfooter('Group Maximum', hsh)
304
+ end
305
+
306
+ ############################################################################
307
+ # Formatting methods
308
+ #
309
+ #
310
+ # :category: Formatting
311
+ #
312
+ # A Formatter can specify a hash to hold the formatting instructions for
313
+ # columns by using the column head as a key and the value as the format
314
+ # instructions. In addition, the keys, :numeric, :string, :datetime,
315
+ # :boolean, and :nil, can be used to specify the default format instructions
316
+ # for columns of the given type is no other instructions have been given.
317
+ #
318
+ # Formatting instructions are strings, and what are valid strings depend on
319
+ # the type of the column:
320
+ #
321
+ # ==== String
322
+ #
323
+ # For string columns, the following instructions are valid:
324
+ #
325
+ # u:: convert the element to all lowercase,
326
+ #
327
+ # U:: convert the element to all uppercase,
328
+ #
329
+ # t:: title case the element, that is, upcase the initial letter in each
330
+ # word and lower case the other letters
331
+ #
332
+ # B or ~B:: make the element bold, or not
333
+ #
334
+ # I or ~I:: make the element italic, or not
335
+ #
336
+ # R:: align the element on the right of the column
337
+ #
338
+ # L:: align the element on the left of the column
339
+ #
340
+ # C:: align the element in the center of the column
341
+ #
342
+ # \c\[color\]:: render the element in the given color; the color can have
343
+ # the form fgcolor, fgcolor.bgcolor, or .bgcolor, to set the
344
+ # foreground or background colors respectively, and each of
345
+ # those can be an ANSI or X11 color name in addition to the
346
+ # special color, 'none', which keeps the terminal's default
347
+ # color.
348
+ #
349
+ # \_ or ~\_:: underline the element, or not,
350
+ #
351
+ # \* ~\*:: cause the element to blink, or not,
352
+ #
353
+ # ==== Numeric
354
+ #
355
+ # For a numeric, all the instructions valid for string are available, in
356
+ # addition to the following:
357
+ #
358
+ # , or ~, :: insert grouping commas, or not,
359
+ #
360
+ # $ or ~$:: format the number as currency according to the locale, or not,
361
+ #
362
+ # m.n:: include at least m digits before the decimal point, padding on the
363
+ # left with zeroes as needed, and round the number to the n decimal
364
+ # places and include n digits after the decimal point, padding on the
365
+ # right with zeroes as needed,
366
+ #
367
+ # H or ~H:: convert the number (assumed to be in units of seconds) to
368
+ # HH:MM:SS.ss form, or not. So a column that is the result of
369
+ # subtracting two :datetime forms will result in a :numeric
370
+ # expressed as seconds and can be displayed in hours, minutes, and
371
+ # seconds with this formatting instruction.
372
+ #
373
+ # ==== DateTime
374
+ #
375
+ # For a DateTime column, all the instructions valid for string are
376
+ # available, in addition to the following:
377
+ #
378
+ # \d\[fmt\]:: apply the format to a datetime that has no or zero hour,
379
+ # minute, second components, where fmt is a valid format string
380
+ # for Date#strftime, otherwise, the datetime will be formatted
381
+ # as an ISO 8601 string, YYYY-MM-DD.
382
+ #
383
+ # \D\[fmt\]:: apply the format to a datetime that has at least a non-zero
384
+ # hour component where fmt is a valid format string for
385
+ # Date#strftime, otherwise, the datetime will be formatted as an
386
+ # ISO 8601 string, YYYY-MM-DD.
387
+ #
388
+ # ==== Boolean
389
+ #
390
+ # All the instructions valid for string are available, in addition to the
391
+ # following:
392
+ #
393
+ # Y:: print true as 'Y' and false as 'N',
394
+ #
395
+ # T:: print true as 'T' and false as 'F',
396
+ #
397
+ # X:: print true as 'X' and false as '',
398
+ #
399
+ # \b\[xxx,yyy\] :: print true as the string given as xxx and false as the
400
+ # string given as yyy,
401
+ #
402
+ # \c\[tcolor,fcolor\]:: color a true element with tcolor and a false element
403
+ # with fcolor. Each of the colors may be specified in
404
+ # the same manner as colors for strings described
405
+ # above.
406
+ #
407
+ # ==== NilClass
408
+ #
409
+ # By default, nil elements are rendered as blank cells, but you can make
410
+ # them visible with the following, and in that case, all the formatting
411
+ # instructions valid for strings are available:
412
+ #
413
+ # \n\[niltext\]:: render a nil item with the given text.
414
+ def format(**fmts)
415
+ [:header, :bfirst, :gfirst, :body, :footer, :gfooter].each do |loc|
416
+ format_for(loc, fmts)
417
+ end
418
+ self
419
+ end
420
+
421
+ # :category: Formatting
422
+ #
423
+ # Define a formatting directives for the given location. The following are
424
+ # the valid +location+ symbols.
425
+ #
426
+ # :header:: instructions for the headers of the table,
427
+ #
428
+ # :bfirst:: instructions for the first row in the body of the table,
429
+ #
430
+ # :gfirst:: instructions for the cells in the first row of a group, to the
431
+ # extent not governed by :bfirst.
432
+ #
433
+ # :body:: instructions for the cells in the body of the table, to the extent
434
+ # they are not governed by :bfirst or :gfirst.
435
+ #
436
+ # :gfooter:: instructions for the cells of a group footer, and
437
+ #
438
+ # :footer:: instructions for the cells of a footer.
439
+ #
440
+ # Formatting directives are specified with hash arguments where the keys are
441
+ # either
442
+ #
443
+ # 1. the name of a table column in symbol form, or
444
+ #
445
+ # 2. the name of a column type in symbol form, i.e., :string, :numeric, or
446
+ # :datetime, :boolean, or :nil (for empty cells or untyped columns).
447
+ #
448
+ # The value given for the hash arguments should be strings that contain
449
+ # "directives" on how elements of that column or of that type are to be
450
+ # formatted on output. Formatting directives for a column name take
451
+ # precedence over those specified by type. And more specific locations take
452
+ # precedence over less specific ones.
453
+ #
454
+ # For example, the first line of a table is part of :body, :gfirst, and
455
+ # :bfirst, but since its identity as the first row of the table is the most
456
+ # specific (there is only one of those, there may be many rows that qualify
457
+ # as :gfirst, and even more that qualify as :body rows) any :bfirst
458
+ # specification would have priority over :gfirst or :body.
459
+ #
460
+ # For purposes of formatting, all headers are considered of the :string type
461
+ # and all nil cells are considered to be of the :nilclass type. All other
462
+ # cells have the type of the column to which they belong, including all
463
+ # cells in group or table footers. See ::format for details on formatting
464
+ # directives.
465
+ def format_for(location, **fmts)
466
+ unless LOCATIONS.include?(location)
467
+ raise UserError, "unknown format location '#{location}'"
468
+ end
469
+ valid_keys = table.headers + [:string, :numeric, :datetime, :boolean, :nil]
470
+ invalid_keys = (fmts.keys - valid_keys).uniq
471
+ unless invalid_keys.empty?
472
+ msg = "invalid #{location} column or type: #{invalid_keys.join(',')}"
473
+ raise UserError, msg
474
+ end
475
+
476
+ @format_at[location] ||= {}
477
+ table.headers.each do |h|
478
+ # Default formatting hash
479
+ format_h =
480
+ if format_at[location][h].empty?
481
+ default_format.dup
482
+ else
483
+ format_at[location][h].to_h
484
+ end
485
+
486
+ unless location == :header
487
+ # Merge in string and nil formatting, but not in header. Header is
488
+ # always typed a string, so it will get formatted in type-based
489
+ # formatting below. And headers are never nil.
490
+ if fmts.keys.include?(:string)
491
+ typ_fmt = parse_string_fmt(fmts[:string])
492
+ format_h = format_h.merge(typ_fmt)
493
+ end
494
+ if fmts.keys.include?(:nil)
495
+ typ_fmt = parse_nil_fmt(fmts[:nil]).first
496
+ format_h = format_h.merge(typ_fmt)
497
+ end
498
+ end
499
+ typ = location == :header ? :string : table.type(h).as_sym
500
+ parse_typ_method_name = 'parse_' + typ.to_s + '_fmt'
501
+ if fmts.keys.include?(typ)
502
+ # Merge in type-based formatting
503
+ typ_fmt = send(parse_typ_method_name, fmts[typ])
504
+ format_h = format_h.merge(typ_fmt)
505
+ end
506
+ if fmts[h]
507
+ # Merge in column formatting
508
+ col_fmt = send(parse_typ_method_name, fmts[h], strict: location != :header)
509
+ format_h = format_h.merge(col_fmt)
510
+ end
511
+
512
+ if location == :body
513
+ # Copy :body formatting for column h to :bfirst and :gfirst if they
514
+ # still have the default formatting. Can be overridden with a format_for
515
+ # call with those locations.
516
+ format_h.each_pair do |k, v|
517
+ if format_at[:bfirst][h].send(k) == default_format[k]
518
+ format_at[:bfirst][h].send("#{k}=", v)
519
+ end
520
+ if format_at[:gfirst][h].send(k) == default_format[k]
521
+ format_at[:gfirst][h].send("#{k}=", v)
522
+ end
523
+ end
524
+ elsif location == :gfirst
525
+ # Copy :gfirst formatting to :bfirst if it is still the default
526
+ format_h.each_pair do |k, v|
527
+ if format_at[:bfirst][h].send(k) == default_format[k]
528
+ format_at[:bfirst][h].send("#{k}=", v)
529
+ end
530
+ end
531
+ end
532
+
533
+ # Record its origin (using leading underscore so not to clash with any
534
+ # headers named h or location) and convert to struct
535
+ format_h[:_h] = h
536
+ format_h[:_location] = location
537
+ format_at[location][h] = OpenStruct.new(format_h)
538
+ end
539
+ self
540
+ end
541
+
542
+ ###############################################################################
543
+ # Parsing and validation routines
544
+ ###############################################################################
545
+
546
+ private
547
+
548
+ # Re to match a color name
549
+ CLR_RE = /(?:[-_a-zA-Z0-9 ]*)/
550
+
551
+ # Return a hash that reflects the formatting instructions given in the
552
+ # string fmt. Raise an error if it contains invalid formatting instructions.
553
+ # If fmt contains conflicting instructions, say C and L, there is no
554
+ # guarantee which will win, but it will not be considered an error to do so.
555
+ def parse_string_fmt(fmt, strict: true)
556
+ format, fmt = parse_str_fmt(fmt)
557
+ unless fmt.blank? || !strict
558
+ raise UserError, "unrecognized string formatting instructions '#{fmt}'"
559
+ end
560
+ format
561
+ end
562
+
563
+ # Utility method that extracts string instructions and returns a hash for
564
+ # of the instructions and the unconsumed part of the instruction string.
565
+ # This is called to cull string-based instructions from a formatting string
566
+ # intended for other types, such as numeric, etc.
567
+ def parse_str_fmt(fmt)
568
+ # We parse the more complex formatting constructs first, and after each
569
+ # parse, we remove the matched construct from fmt. At the end, any
570
+ # remaining characters in fmt should be invalid.
571
+ fmt_hash = {}
572
+ if fmt =~ /c\[(#{CLR_RE})(\.(#{CLR_RE}))?\]/
573
+ fmt_hash[:color] = $1 unless $1.blank?
574
+ fmt_hash[:bgcolor] = $3 unless $3.blank?
575
+ validate_color(fmt_hash[:color])
576
+ validate_color(fmt_hash[:bgcolor])
577
+ fmt = fmt.sub($&, '')
578
+ end
579
+ # Nil formatting can apply to strings as well
580
+ nil_hash, fmt = parse_nil_fmt(fmt)
581
+ fmt_hash = fmt_hash.merge(nil_hash)
582
+ if fmt =~ /u/
583
+ fmt_hash[:case] = :lower
584
+ fmt = fmt.sub($&, '')
585
+ end
586
+ if fmt =~ /U/
587
+ fmt_hash[:case] = :upper
588
+ fmt = fmt.sub($&, '')
589
+ end
590
+ if fmt =~ /t/
591
+ fmt_hash[:case] = :title
592
+ fmt = fmt.sub($&, '')
593
+ end
594
+ if fmt =~ /(~\s*)?B/
595
+ fmt_hash[:bold] = !!!$1
596
+ fmt = fmt.sub($&, '')
597
+ end
598
+ if fmt =~ /(~\s*)?I/
599
+ fmt_hash[:italic] = !!!$1
600
+ fmt = fmt.sub($&, '')
601
+ end
602
+ if fmt =~ /R/
603
+ fmt_hash[:alignment] = :right
604
+ fmt = fmt.sub($&, '')
605
+ end
606
+ if fmt =~ /C/
607
+ fmt_hash[:alignment] = :center
608
+ fmt = fmt.sub($&, '')
609
+ end
610
+ if fmt =~ /L/
611
+ fmt_hash[:alignment] = :left
612
+ fmt = fmt.sub($&, '')
613
+ end
614
+ if fmt =~ /(~\s*)?_/
615
+ fmt_hash[:underline] = !!!$1
616
+ fmt = fmt.sub($&, '')
617
+ end
618
+ if fmt =~ /(~\s*)?\*/
619
+ fmt_hash[:blink] = !!!$1
620
+ fmt = fmt.sub($&, '')
621
+ end
622
+ [fmt_hash, fmt]
623
+ end
624
+
625
+ # Utility method that extracts nil instructions and returns a hash of the
626
+ # instructions and the unconsumed part of the instruction string. This is
627
+ # called to cull nil-based instructions from a formatting string intended
628
+ # for other types, such as numeric, etc.
629
+ def parse_nil_fmt(fmt, _strict: true)
630
+ # We parse the more complex formatting constructs first, and after each
631
+ # parse, we remove the matched construct from fmt. At the end, any
632
+ # remaining characters in fmt should be invalid.
633
+ fmt_hash = {}
634
+ if fmt =~ /n\[\s*([^\]]*)\s*\]/
635
+ fmt_hash[:nil_text] = $1.clean
636
+ fmt = fmt.sub($&, '')
637
+ end
638
+ [fmt_hash, fmt]
639
+ end
640
+
641
+ # Return a hash that reflects the numeric or string formatting instructions
642
+ # given in the string fmt. Raise an error if it contains invalid formatting
643
+ # instructions. If fmt contains conflicting instructions, there is no
644
+ # guarantee which will win, but it will not be considered an error to do so.
645
+ def parse_numeric_fmt(fmt, strict: true)
646
+ # We parse the more complex formatting constructs first, and after each
647
+ # parse, we remove the matched construct from fmt. At the end, any
648
+ # remaining characters in fmt should be invalid.
649
+ fmt_hash, fmt = parse_str_fmt(fmt)
650
+ if fmt =~ /(\d+).(\d+)/
651
+ fmt_hash[:pre_digits] = $1.to_i
652
+ fmt_hash[:post_digits] = $2.to_i
653
+ fmt = fmt.sub($&, '')
654
+ end
655
+ if fmt =~ /(~\s*)?,/
656
+ fmt_hash[:commas] = !!!$1
657
+ fmt = fmt.sub($&, '')
658
+ end
659
+ if fmt =~ /(~\s*)?\$/
660
+ fmt_hash[:currency] = !!!$1
661
+ fmt = fmt.sub($&, '')
662
+ end
663
+ if fmt =~ /(~\s*)?H/
664
+ fmt_hash[:hms] = !!!$1
665
+ fmt = fmt.sub($&, '')
666
+ end
667
+ unless fmt.blank? || !strict
668
+ raise UserError, "unrecognized numeric formatting instructions '#{fmt}'"
669
+ end
670
+ fmt_hash
671
+ end
672
+
673
+ # Return a hash that reflects the datetime or string formatting instructions
674
+ # given in the string fmt. Raise an error if it contains invalid formatting
675
+ # instructions. If fmt contains conflicting instructions, there is no
676
+ # guarantee which will win, but it will not be considered an error to do so.
677
+ def parse_datetime_fmt(fmt, strict: true)
678
+ # We parse the more complex formatting constructs first, and after each
679
+ # parse, we remove the matched construct from fmt. At the end, any
680
+ # remaining characters in fmt should be invalid.
681
+ fmt_hash, fmt = parse_str_fmt(fmt)
682
+ if fmt =~ /d\[([^\]]*)\]/
683
+ fmt_hash[:date_fmt] = $1
684
+ fmt = fmt.sub($&, '')
685
+ end
686
+ if fmt =~ /D\[([^\]]*)\]/
687
+ fmt_hash[:date_fmt] = $1
688
+ fmt = fmt.sub($&, '')
689
+ end
690
+ unless fmt.blank? || !strict
691
+ raise UserError, "unrecognized datetime formatting instructions '#{fmt}'"
692
+ end
693
+ fmt_hash
694
+ end
695
+
696
+ # Return a hash that reflects the boolean or string formatting instructions
697
+ # given in the string fmt. Raise an error if it contains invalid formatting
698
+ # instructions. If fmt contains conflicting instructions, there is no
699
+ # guarantee which will win, but it will not be considered an error to do so.
700
+ def parse_boolean_fmt(fmt, strict: true)
701
+ # We parse the more complex formatting constructs first, and after each
702
+ # parse, we remove the matched construct from fmt. At the end, any
703
+ # remaining characters in fmt should be invalid.
704
+ fmt_hash = {}
705
+ if fmt =~ /b\[\s*([^\],]*),([^\]]*)\s*\]/
706
+ fmt_hash[:true_text] = $1.clean
707
+ fmt_hash[:false_text] = $2.clean
708
+ fmt = fmt.sub($&, '')
709
+ end
710
+ # Since true_text, false_text and nil_text may want to have internal
711
+ # spaces, defer removing extraneous spaces until after they are parsed.
712
+ if fmt =~ /c\[(#{CLR_RE})(\.(#{CLR_RE}))?,\s*(#{CLR_RE})(\.(#{CLR_RE}))?\]/
713
+ fmt_hash[:true_color] = $1 unless $1.blank?
714
+ fmt_hash[:true_bgcolor] = $3 unless $3.blank?
715
+ fmt_hash[:false_color] = $4 unless $4.blank?
716
+ fmt_hash[:false_bgcolor] = $6 unless $6.blank?
717
+ fmt = fmt.sub($&, '')
718
+ end
719
+ str_fmt_hash, fmt = parse_str_fmt(fmt)
720
+ fmt_hash = fmt_hash.merge(str_fmt_hash)
721
+ if fmt =~ /Y/
722
+ fmt_hash[:true_text] = 'Y'
723
+ fmt_hash[:false_text] = 'N'
724
+ fmt = fmt.sub($&, '')
725
+ end
726
+ if fmt =~ /T/
727
+ fmt_hash[:true_text] = 'T'
728
+ fmt_hash[:false_text] = 'F'
729
+ fmt = fmt.sub($&, '')
730
+ end
731
+ if fmt =~ /X/
732
+ fmt_hash[:true_text] = 'X'
733
+ fmt_hash[:false_text] = ''
734
+ fmt = fmt.sub($&, '')
735
+ end
736
+ unless fmt.blank? || !strict
737
+ raise UserError, "unrecognized boolean formatting instructions '#{fmt}'"
738
+ end
739
+ fmt_hash
740
+ end
741
+
742
+ ###############################################################################
743
+ # Applying formatting
744
+ ###############################################################################
745
+
746
+ public
747
+
748
+ # :stopdoc:
749
+
750
+ # Convert a value to a string based on the instructions in istruct,
751
+ # depending on the type of val. "Formatting," which changes the content of
752
+ # the string, such as adding commas, is always performed, except alignment
753
+ # which is only performed when the width parameter is non-nil. "Decorating",
754
+ # which changes the appearance without changing the content, is performed
755
+ # only if the decorate parameter is true.
756
+ def format_cell(val, istruct, width: nil, decorate: false)
757
+ case val
758
+ when Numeric
759
+ str = format_numeric(val, istruct)
760
+ str = format_string(str, istruct, width)
761
+ decorate ? decorate_string(str, istruct) : str
762
+ when DateTime, Date
763
+ str = format_datetime(val, istruct)
764
+ str = format_string(str, istruct, width)
765
+ decorate ? decorate_string(str, istruct) : str
766
+ when TrueClass
767
+ str = format_boolean(val, istruct)
768
+ str = format_string(str, istruct, width)
769
+ true_istruct = istruct.dup
770
+ true_istruct.color = istruct.true_color
771
+ true_istruct.bgcolor = istruct.true_bgcolor
772
+ decorate ? decorate_string(str, true_istruct) : str
773
+ when FalseClass
774
+ str = format_boolean(val, istruct)
775
+ str = format_string(str, istruct, width)
776
+ false_istruct = istruct.dup
777
+ false_istruct.color = istruct.false_color
778
+ false_istruct.bgcolor = istruct.false_bgcolor
779
+ decorate ? decorate_string(str, false_istruct) : str
780
+ when NilClass
781
+ str = istruct.nil_text
782
+ str = format_string(str, istruct, width)
783
+ decorate ? decorate_string(str, istruct) : str
784
+ when String
785
+ str = format_string(val, istruct, width)
786
+ decorate ? decorate_string(str, istruct) : str
787
+ else
788
+ raise UserError,
789
+ "cannot format value '#{val}' of class #{val.class}"
790
+ end
791
+ end
792
+
793
+ private
794
+
795
+ # Add LaTeX control sequences, ANSI terminal escape codes, or other
796
+ # decorations to string to decorate it with the given attributes. None of
797
+ # the decorations may affect the displayed width of the string. Return the
798
+ # decorated string.
799
+ def decorate_string(str, _istruct)
800
+ str
801
+ end
802
+
803
+ # Convert a boolean to a string according to instructions in istruct, which
804
+ # is assumed to be the result of parsing a formatting instruction string as
805
+ # above. Only device-independent formatting is done here. Device dependent
806
+ # formatting (e.g., color) can be done in a subclass of Formatter by
807
+ # specializing this method.
808
+ def format_boolean(val, istruct)
809
+ return istruct.nil_text if val.nil?
810
+ val ? istruct.true_text : istruct.false_text
811
+ end
812
+
813
+ # Convert a datetime to a string according to instructions in istruct, which
814
+ # is assumed to be the result of parsing a formatting instruction string as
815
+ # above. Only device-independent formatting is done here. Device dependent
816
+ # formatting (e.g., color) can be done in a subclass of Formatter by
817
+ # specializing this method.
818
+ def format_datetime(val, istruct)
819
+ return istruct.nil_text if val.nil?
820
+ if val.to_date == val
821
+ # It is a Date, with no time component.
822
+ val.strftime(istruct.date_fmt)
823
+ else
824
+ val.strftime(istruct.datetime_fmt)
825
+ end
826
+ end
827
+
828
+ # Convert a numeric to a string according to instructions in istruct, which
829
+ # is assumed to be the result of parsing a formatting instruction string as
830
+ # above. Only device-independent formatting is done here. Device dependent
831
+ # formatting (e.g., color) can be done in a subclass of Formatter by
832
+ # specializing this method.
833
+ def format_numeric(val, istruct)
834
+ return istruct.nil_text if val.nil?
835
+ val = val.round(istruct.post_digits) if istruct.post_digits >= 0
836
+ if istruct.hms
837
+ result = val.secs_to_hms
838
+ istruct.commas = false
839
+ elsif istruct.currency
840
+ prec = istruct.post_digits == 0 ? 2 : istruct.post_digits
841
+ delim = istruct.commas ? ',' : ''
842
+ result = val.to_s(:currency, precision: prec, delimiter: delim,
843
+ unit: FatTable.currency_symbol)
844
+ istruct.commas = false
845
+ elsif istruct.pre_digits.positive?
846
+ if val.whole?
847
+ # No fractional part, ignore post_digits
848
+ result = sprintf("%0#{istruct.pre_digits}d", val)
849
+ elsif istruct.post_digits >= 0
850
+ # There's a fractional part and pre_digits. sprintf width includes
851
+ # space for fractional part and decimal point, so add pre, post, and 1
852
+ # to get the proper sprintf width.
853
+ wid = istruct.pre_digits + 1 + istruct.post_digits
854
+ result = sprintf("%0#{wid}.#{istruct.post_digits}f", val)
855
+ else
856
+ val = val.round(0)
857
+ result = sprintf("%0#{istruct.pre_digits}d", val)
858
+ end
859
+ elsif istruct.post_digits >= 0
860
+ # Round to post_digits but no padding of whole number, pad fraction with
861
+ # trailing zeroes.
862
+ result = sprintf("%.#{istruct.post_digits}f", val)
863
+ else
864
+ result = val.to_s
865
+ end
866
+ if istruct.commas
867
+ # Commify the whole number part if not done already.
868
+ result = result.commify
869
+ end
870
+ result
871
+ end
872
+
873
+ # Apply non-device-dependent string formatting instructions.
874
+ def format_string(val, istruct, width = nil)
875
+ val = istruct.nil_text if val.nil?
876
+ val =
877
+ case istruct.case
878
+ when :lower
879
+ val.downcase
880
+ when :upper
881
+ val.upcase
882
+ when :title
883
+ val.entitle
884
+ when :none
885
+ val
886
+ end
887
+ if width && aligned?
888
+ pad = width - width(val)
889
+ case istruct.alignment
890
+ when :left
891
+ val += ' ' * pad
892
+ when :right
893
+ val = ' ' * pad + val
894
+ when :center
895
+ lpad = pad / 2 + (pad.odd? ? 1 : 0)
896
+ rpad = pad / 2
897
+ val = ' ' * lpad + val + ' ' * rpad
898
+ else
899
+ val = val
900
+ end
901
+ val = ' ' + val + ' '
902
+ end
903
+ val
904
+ end
905
+
906
+ ###############################################################################
907
+ # Output routines
908
+ ###############################################################################
909
+
910
+ public
911
+
912
+ # :startdoc:
913
+
914
+ # :category: Output
915
+
916
+ # Return the +table+ as either a string in the target format or as a Ruby
917
+ # data structure if that is the target. In the latter case, all the cells
918
+ # are converted to strings formatted according to the Formatter's formatting
919
+ # directives given in Formatter.format_for or Formatter.format.
920
+ def output
921
+ # This results in a hash of two-element arrays. The key is the header and
922
+ # the value is an array of the header and formatted header. We do the
923
+ # latter so the structure parallels the structure for rows explained next.
924
+ formatted_headers = build_formatted_headers
925
+
926
+ # These produce an array with each element representing a row of the
927
+ # table. Each element of the array is a two-element array. The location of
928
+ # the row in the table (:bfirst, :body, :gfooter, etc.) is the first
929
+ # element and a hash of the row is the second element. The keys for the
930
+ # hash are the row headers as in the Table, but the values are two element
931
+ # arrays as well. First is the raw, unformatted value of the cell, the
932
+ # second is a string of the first value formatted according to the
933
+ # instructions for the column and location in which it appears. The
934
+ # formatting done on this pass is only formatting that affects the
935
+ # contents of the cells, such as inserting commas, that would affect the
936
+ # width of the columns as displayed. We keep both the raw value and
937
+ # unformatted value around because we have to make two passes over the
938
+ # table if there is any alignment, and we want to know the type of the raw
939
+ # element for the second pass of formatting for type-specific formatting
940
+ # (e.g., true_color, false_color, etc.).
941
+ new_rows = build_formatted_body
942
+ new_rows += build_formatted_footers
943
+
944
+ # Having formatted the cells, we can now compute column widths so we can
945
+ # do any alignment called for if this is a Formatter that performs its own
946
+ # alignment. On this pass, we also decorate the cells with colors, bold,
947
+ # etc.
948
+ if aligned?
949
+ widths = width_map(formatted_headers, new_rows)
950
+ table.headers.each do |h|
951
+ fmt_h = formatted_headers[h].last
952
+ istruct = format_at[:header][h]
953
+ formatted_headers[h] =
954
+ [h, format_cell(fmt_h, istruct, width: widths[h], decorate: true)]
955
+ end
956
+ aligned_rows = []
957
+ new_rows.each do |loc_row|
958
+ if loc_row.nil?
959
+ aligned_rows << nil
960
+ next
961
+ end
962
+ loc, row = *loc_row
963
+ aligned_row = {}
964
+ row.each_pair do |h, (val, _fmt_v)|
965
+ istruct = format_at[loc][h]
966
+ aligned_row[h] =
967
+ [val, format_cell(val, istruct, width: widths[h], decorate: true)]
968
+ end
969
+ aligned_rows << [loc, aligned_row]
970
+ end
971
+ new_rows = aligned_rows
972
+ end
973
+
974
+ # Now that the contents of the output table cells have been computed and
975
+ # alignment applied, we can actually construct the table using the methods
976
+ # for constructing table parts, pre_table, etc. We expect that these will
977
+ # be overridden by subclasses of Formatter for specific output targets. In
978
+ # any event, the result is a single string (or ruby object if eval is true
979
+ # for the Formatter) representing the table in the syntax of the output
980
+ # target.
981
+ result = ''
982
+ result += pre_table
983
+ if include_header_row?
984
+ result += pre_header(widths)
985
+ result += pre_row
986
+ cells = []
987
+ formatted_headers.each_pair do |h, (_v, fmt_v)|
988
+ cells << pre_cell(h) + quote_cell(fmt_v) + post_cell
989
+ end
990
+ result += cells.join(inter_cell)
991
+ result += post_row
992
+ result += post_header(widths)
993
+ end
994
+ new_rows.each do |loc_row|
995
+ result += hline(widths) if loc_row.nil?
996
+ next if loc_row.nil?
997
+ _loc, row = *loc_row
998
+ result += pre_row
999
+ cells = []
1000
+ row.each_pair do |h, (_v, fmt_v)|
1001
+ cells << pre_cell(h) + quote_cell(fmt_v) + post_cell
1002
+ end
1003
+ result += cells.join(inter_cell)
1004
+ result += post_row
1005
+ end
1006
+ result += post_footers(widths)
1007
+ result += post_table
1008
+
1009
+ # If this Formatter targets a ruby data structure (e.g., AoaFormatter), we
1010
+ # eval the string to get the object.
1011
+ evaluate? ? eval(result) : result
1012
+ end
1013
+
1014
+ private
1015
+
1016
+ # Return a hash mapping the table's headers to their formatted versions. If
1017
+ # a hash of column widths is given, perform alignment within the given field
1018
+ # widths.
1019
+ def build_formatted_headers(widths = {})
1020
+ # Don't decorate if this Formatter calls for alignment. It will be done
1021
+ # in the second pass.
1022
+ decorate = !aligned?
1023
+ map = {}
1024
+ table.headers.each do |h|
1025
+ istruct = format_at[:header][h]
1026
+ map[h] = [h, format_cell(h.as_string, istruct, decorate: decorate)]
1027
+ end
1028
+ map
1029
+ end
1030
+
1031
+ # Return an array of two-element arrays, with the first element of the inner
1032
+ # array being the location of the row and the second element being a hash,
1033
+ # using the table's headers as keys and an array of the raw and
1034
+ # formatted cells as the values. Add formatted group footers along the way.
1035
+ def build_formatted_body
1036
+ # Don't decorate if this Formatter calls for alignment. It will be done
1037
+ # in the second pass.
1038
+ decorate = !aligned?
1039
+ new_rows = []
1040
+ tbl_row_k = 0
1041
+ table.groups.each_with_index do |grp, grp_k|
1042
+ # Mark the beginning of a group if this is the first group after the
1043
+ # header or the second or later group.
1044
+ new_rows << nil if include_header_row? || grp_k.positive?
1045
+ # Compute group body
1046
+ grp_col = {}
1047
+ grp.each_with_index do |row, grp_row_k|
1048
+ new_row = {}
1049
+ location =
1050
+ if tbl_row_k.zero?
1051
+ :bfirst
1052
+ elsif grp_row_k.zero?
1053
+ :gfirst
1054
+ else
1055
+ :body
1056
+ end
1057
+ table.headers.each do |h|
1058
+ grp_col[h] ||= Column.new(header: h)
1059
+ grp_col[h] << row[h]
1060
+ istruct = format_at[location][h]
1061
+ new_row[h] = [row[h], format_cell(row[h], istruct, decorate: decorate)]
1062
+ end
1063
+ new_rows << [location, new_row]
1064
+ tbl_row_k += 1
1065
+ end
1066
+ # Compute group footers
1067
+ gfooters.each_pair do |label, gfooter|
1068
+ # Mark the beginning of a group footer
1069
+ new_rows << nil
1070
+ gfoot_row = {}
1071
+ first_h = nil
1072
+ grp_col.each_pair do |h, col|
1073
+ first_h ||= h
1074
+ gfoot_row[h] =
1075
+ if gfooter[h]
1076
+ val = col.send(gfooter[h])
1077
+ istruct = format_at[:gfooter][h]
1078
+ [val, format_cell(val, istruct, decorate: decorate)]
1079
+ else
1080
+ [nil, '']
1081
+ end
1082
+ end
1083
+ if gfoot_row[first_h].last.blank?
1084
+ istruct = format_at[:gfooter][first_h]
1085
+ gfoot_row[first_h] =
1086
+ [label, format_cell(label, istruct, decorate: decorate)]
1087
+ end
1088
+ new_rows << [:gfooter, gfoot_row]
1089
+ end
1090
+ end
1091
+ new_rows
1092
+ end
1093
+
1094
+ def build_formatted_footers
1095
+ # Don't decorate if this Formatter calls for alignment. It will be done
1096
+ # in the second pass.
1097
+ decorate = !aligned?
1098
+ new_rows = []
1099
+ # Done with body, compute the table footers.
1100
+ footers.each_pair do |label, footer|
1101
+ # Mark the beginning of a footer
1102
+ new_rows << nil
1103
+ foot_row = {}
1104
+ first_h = nil
1105
+ table.columns.each do |col|
1106
+ h = col.header
1107
+ first_h ||= h
1108
+ foot_row[h] =
1109
+ if footer[h]
1110
+ val = col.send(footer[h])
1111
+ istruct = format_at[:footer][h]
1112
+ [val, format_cell(val, istruct, decorate: decorate)]
1113
+ else
1114
+ [nil, '']
1115
+ end
1116
+ end
1117
+ # Put the label in the first column of footer unless it has been
1118
+ # formatted as part of footer.
1119
+ if foot_row[first_h].last.blank?
1120
+ istruct = format_at[:footer][first_h]
1121
+ foot_row[first_h] =
1122
+ [label, format_cell(label, istruct, decorate: decorate)]
1123
+ end
1124
+ new_rows << [:footer, foot_row]
1125
+ end
1126
+ new_rows
1127
+ end
1128
+
1129
+ # Return a hash of the maximum widths of all the given headers and rows.
1130
+ def width_map(formatted_headers, rows)
1131
+ widths = {}
1132
+ formatted_headers.each_pair do |h, (_v, fmt_v)|
1133
+ widths[h] ||= 0
1134
+ widths[h] = [widths[h], width(fmt_v)].max
1135
+ end
1136
+ rows.each do |loc_row|
1137
+ next if loc_row.nil?
1138
+ _loc, row = *loc_row
1139
+ row.each_pair do |h, (_v, fmt_v)|
1140
+ widths[h] ||= 0
1141
+ widths[h] = [widths[h], width(fmt_v)].max
1142
+ end
1143
+ end
1144
+ widths
1145
+ end
1146
+
1147
+ # Raise an error unless the given color is valid for this Formatter.
1148
+ def validate_color(clr)
1149
+ return true unless clr
1150
+ raise UserError, invalid_color_msg(clr) unless color_valid?(clr)
1151
+ end
1152
+
1153
+ ###########################################################################
1154
+ # Class-specific methods. Many of these should be overriden in any subclass
1155
+ # of Formatter to implement a specific target output medium.
1156
+ ###########################################################################
1157
+
1158
+ # Return whether clr is a valid color for this Formatter
1159
+ def color_valid?(_clr)
1160
+ true
1161
+ end
1162
+
1163
+ # Return an error message string to display when clr is an invalid color.
1164
+ def invalid_color_msg(_clr)
1165
+ ''
1166
+ end
1167
+
1168
+ # Does this Formatter require a second pass over the cells to align the
1169
+ # columns according to the alignment formatting instruction to the width of
1170
+ # the widest cell in each column? If no alignment is needed, as for
1171
+ # AoaFormatter, or if the external target medium does alignment, as for
1172
+ # LaTeXFormatter, this should be false. For TextFormatter or TermFormatter,
1173
+ # where we must pad out the cells with spaces, it should be true.
1174
+ def aligned?
1175
+ false
1176
+ end
1177
+
1178
+ # Should the string result of #output be evaluated to form a Ruby data
1179
+ # structure? For example, AoaFormatter wants to return an array of arrays of
1180
+ # strings, so it should build a ruby expression to do that, then have it
1181
+ # eval'ed.
1182
+ def evaluate?
1183
+ false
1184
+ end
1185
+
1186
+ # Compute the width of the string as displayed, taking into account the
1187
+ # characteristics of the target device. For example, a colored string
1188
+ # should not include in the width terminal control characters that simply
1189
+ # change the color without occupying any space. Thus, this method must be
1190
+ # overridden in a subclass if a simple character count does not reflect the
1191
+ # width as displayed.
1192
+ def width(str)
1193
+ str.length
1194
+ end
1195
+
1196
+ def pre_table
1197
+ ''
1198
+ end
1199
+
1200
+ def post_table
1201
+ ''
1202
+ end
1203
+
1204
+ def include_header_row?
1205
+ true
1206
+ end
1207
+
1208
+ def pre_header(_widths)
1209
+ ''
1210
+ end
1211
+
1212
+ def post_header(_widths)
1213
+ ''
1214
+ end
1215
+
1216
+ def pre_row
1217
+ ''
1218
+ end
1219
+
1220
+ def pre_cell(_h)
1221
+ ''
1222
+ end
1223
+
1224
+ def quote_cell(v)
1225
+ v
1226
+ end
1227
+
1228
+ def post_cell
1229
+ ''
1230
+ end
1231
+
1232
+ def inter_cell
1233
+ '|'
1234
+ end
1235
+
1236
+ def post_row
1237
+ "\n"
1238
+ end
1239
+
1240
+ def hline(_widths)
1241
+ ''
1242
+ end
1243
+
1244
+ def post_footers(_widths)
1245
+ ''
1246
+ end
1247
+ end
1248
+ end