fat_table 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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