fat_core 1.6.0 → 1.7.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,973 @@
1
+ module FatCore
2
+ ## A formatter is for use in Table output routines, and provides instructions
3
+ ## for how the table ought to be formatted. The goal is to make subclasses of
4
+ ## this class to handle different output targets, such as aoa for org tables,
5
+ ## ansi terminals, LaTeX, html, plain text, org mode table text, and so forth.
6
+ ## Many of the formatting options, such as color, will be no-ops for some
7
+ ## output targets, such as text, but will be valid nonetheless. Thus, the
8
+ ## Formatter subclass should provide the best implementation for each
9
+ ## formatting request available for the target. This base class will consist
10
+ ## largely of stub methods with implementations provided by the subclass.
11
+ class Formatter
12
+ LOCATIONS = [:header, :body, :bfirst, :gfirst, :gfooter, :footer].freeze
13
+
14
+ attr_reader :table, :format_at, :footers, :gfooters
15
+
16
+ class_attribute :default_format
17
+ self.default_format = {
18
+ nil_text: '',
19
+ case: :none,
20
+ alignment: :left,
21
+ bold: false,
22
+ italic: false,
23
+ color: 'black',
24
+ hms: false,
25
+ pre_digits: -1,
26
+ post_digits: -1,
27
+ commas: false,
28
+ currency: false,
29
+ datetime_fmt: '%F %H:%M:%S',
30
+ date_fmt: '%F',
31
+ true_text: 'T',
32
+ false_text: 'F',
33
+ true_color: 'black',
34
+ false_color: 'black'
35
+ }
36
+
37
+ class_attribute :currency_symbol
38
+ self.currency_symbol = '$'
39
+
40
+ # def self.currency_symbol=(char)
41
+ # @@currency_symbol = char.to_s
42
+ # end
43
+
44
+ # def self.currency_symbol
45
+ # @@currency_symbol
46
+ # end
47
+
48
+ # A Formatter can specify a hash to hold the formatting instructions for
49
+ # columns by using the column head as a key and the value as the format
50
+ # instructions. In addition, the keys, :numeric, :string, :datetime,
51
+ # :boolean, and :nil, can be used to specify the default format instructions
52
+ # for columns of the given type is no other instructions have been given.
53
+ #
54
+ # Formatting instructions are strings, and what are valid strings depend on
55
+ # the type of the column:
56
+ #
57
+ # - string :: for string columns, the following instructions are valid:
58
+ # + u :: convert the element to all lowercase,
59
+ # + U :: convert the element to all uppercase,
60
+ # + t :: title case the element, that is, upcase the initial letter in
61
+ # each word and lower case the other letters
62
+ # + B :: make the element bold
63
+ # + I :: make the element italic
64
+ # + R :: align the element on the right of the column
65
+ # + L :: align the element on the left of the column
66
+ # + C :: align the element in the center of the column
67
+ # + c[color] :: render the element in the given color
68
+ # - numeric :: for a numeric, all the instructions valid for string are
69
+ # available, in addition to the following:
70
+ # + , :: insert grouping commas,
71
+ # + $ :: format the number as currency according to the locale,
72
+ # + m.n :: include at least m digits before the decimal point, padding on
73
+ # the left with zeroes as needed, and round the number to the n
74
+ # decimal places and include n digits after the decimal point,
75
+ # padding on the right with zeroes as needed,
76
+ # + H :: convert the number (assumed to be in units of seconds) to
77
+ # HH:MM:SS.ss form. So a column that is the result of subtracting
78
+ # two :datetime forms will result in a :numeric expressed as seconds
79
+ # and can be displayed in hours, minutes, and seconds with this
80
+ # formatting instruction.
81
+ # - datetime :: for a datetime, all the instructions valid for string are
82
+ # available, in addition to the following:
83
+ # + d[fmt] :: apply the format to a datetime that has no or zero hour,
84
+ # minute, second components, where fmt is a valid format string for
85
+ # Date#strftime, otherwise, the datetime will be formatted as an ISO
86
+ # 8601 string, YYYY-MM-DD.
87
+ # + D[fmt] :: apply the format to a datetime that has at least a non-zero
88
+ # hour component where fmt is a valid format string for
89
+ # Date#strftime, otherwise, the datetime will be formatted as an ISO
90
+ # 8601 string, YYYY-MM-DD.
91
+ # - boolean :: all the instructions valid for string are available, in
92
+ # addition to the following:
93
+ # + Y :: print true as 'Y' and false as 'N',
94
+ # + T :: print true as 'T' and false as 'F',
95
+ # + X :: print true as 'X' and false as '',
96
+ # + b[xxx,yyy] :: print true as the string given as xxx and false as the
97
+ # string given as yyy,
98
+ # + c[tcolor,fcolor] :: color a true element with tcolor and a false
99
+ # element with fcolor.
100
+ # - nil :: by default, nil elements are rendered as blank cells, but you can
101
+ # make them visible with the following, and in that case, all the
102
+ # formatting instructions valid for strings are available:
103
+ # + n[niltext] :: render a nil item with the given text.
104
+ #
105
+ # In the foregoing, the earlier elements in each list will be available for
106
+ # all formatter subclasses, while the later elements may or may not have any
107
+ # effect on the output.
108
+ #
109
+ # The hashes that can be specified to the formatter determine the formatting
110
+ # instructions for different parts of the output table:
111
+ #
112
+ # - header: :: instructions for the headers of the table,
113
+ # - bfirst :: instructions for the first row in the body of the table,
114
+ # - gfirst :: instructions for the cells in the first row of a group,
115
+ # - body :: instructions for the cells in the body of the table, to the
116
+ # extent they are not governed by bfirst or gfirst.
117
+ # - gfooter :: instructions for the cells of a group footer, and
118
+ # - footer :: instructions for the cells of a footer.
119
+ #
120
+ def initialize(table = Table.new)
121
+ unless table && table.is_a?(Table)
122
+ raise ArgumentError, 'must initialize Formatter with a Table'
123
+ end
124
+ @table = table
125
+ @footers = {}
126
+ @gfooters = {}
127
+ # Formatting instructions for various "locations" within the Table, as
128
+ # a hash of hashes. The outer hash is keyed on the location, and each
129
+ # inner hash is keyed on either a column sym or a type sym, :string, :numeric,
130
+ # :datetime, :boolean, or :nil. The value of the inner hashes are
131
+ # OpenStruct structs.
132
+ @format_at = {}
133
+ [:header, :bfirst, :gfirst, :body, :footer, :gfooter].each do |loc|
134
+ @format_at[loc] = {}
135
+ table.headers.each do |h|
136
+ format_at[loc][h] = OpenStruct.new(self.class.default_format)
137
+ end
138
+ end
139
+ yield self if block_given?
140
+ end
141
+
142
+ ############################################################################
143
+ # Footer methods
144
+ #
145
+ #
146
+ # A Table may have any number of footers and any number of group footers.
147
+ # Footers are not part of the table's data and never participate in any of
148
+ # the transformation methods on tables. They are never inherited by output
149
+ # tables from input tables in any of the transformation methods.
150
+ #
151
+ # When output, a table footer will appear at the bottom of the table, and a
152
+ # group footer will appear at the bottom of each group.
153
+ #
154
+ # Each footer must have a label, usually a string such as 'Total', to
155
+ # identify the purpose of the footer, and the label must be distinct among
156
+ # all footers of the same type. That is you may have a table footer labeled
157
+ # 'Total' and a group footer labeled 'Total', but you may not have two table
158
+ # footers with that label. If the first column of the table is not included
159
+ # in the footer, the footer's label will be placed there, otherwise, there
160
+ # will be no label output. The footers are accessible with the #footers
161
+ # method, which returns a hash indexed by the label converted to a symbol.
162
+ # The symbol is reconverted to a title-cased string on output.
163
+ #
164
+ # Note that by adding footers or gfooters to the table, you are only stating
165
+ # what footers you want on output of the table. No actual calculation is
166
+ # performed until the table is output.
167
+ #
168
+ ############################################################################
169
+
170
+ public
171
+
172
+ # Add a table footer to the table with a label given in the first parameter,
173
+ # defaulting to 'Total'. After the label, you can given any number of
174
+ # headers (as symbols) for columns to be summed, and then any number of hash
175
+ # parameters for columns for with to apply an aggregate other than :sum.
176
+ # For example, these are valid footer definitions.
177
+ #
178
+ # # Just sum the shares column with a label of 'Total'
179
+ # tab.footer(:shares)
180
+ #
181
+ # # Change the label and sum the :price column as well
182
+ # tab.footer('Grand Total', :shares, :price)
183
+ #
184
+ # # Average then show standard deviation of several columns
185
+ # tab.footer.('Average', date: avg, shares: :avg, price: avg)
186
+ # tab.footer.('Sigma', date: dev, shares: :dev, price: :dev)
187
+ #
188
+ # # Do some sums and some other aggregates: sum shares, average date and
189
+ # # price.
190
+ # tab.footer.('Summary', :shares, date: avg, price: avg)
191
+ def footer(label, *sum_cols, **agg_cols)
192
+ label = label.to_s
193
+ foot = {}
194
+ sum_cols.each do |h|
195
+ unless table.headers.include?(h)
196
+ raise "No '#{h}' column in table to sum in the footer"
197
+ end
198
+ foot[h] = :sum
199
+ end
200
+ agg_cols.each do |h, agg|
201
+ unless table.headers.include?(h)
202
+ raise "No '#{h}' column in table to #{aggregate} in the footer"
203
+ end
204
+ foot[h] = agg
205
+ end
206
+ @footers[label] = foot
207
+ self
208
+ end
209
+
210
+ def gfooter(label, *sum_cols, **agg_cols)
211
+ label = label.to_s
212
+ foot = {}
213
+ sum_cols.each do |h|
214
+ unless table.headers.include?(h)
215
+ raise "No '#{h}' column in table to sum in the group footer"
216
+ end
217
+ foot[h] = :sum
218
+ end
219
+ agg_cols.each do |h, agg|
220
+ unless table.headers.include?(h)
221
+ raise "No '#{h}' column in table to #{aggregate} in the group footer"
222
+ end
223
+ foot[h] = agg
224
+ end
225
+ @gfooters[label] = foot
226
+ self
227
+ end
228
+
229
+ def sum_footer(*cols)
230
+ footer('Total', *cols)
231
+ end
232
+
233
+ def sum_gfooter(*cols)
234
+ gfooter('Group Total', *cols)
235
+ end
236
+
237
+ def avg_footer(*cols)
238
+ hsh = {}
239
+ cols.each do |c|
240
+ hsh[c] = :avg
241
+ end
242
+ footer('Average', hsh)
243
+ end
244
+
245
+ def avg_gfooter(*cols)
246
+ hsh = {}
247
+ cols.each do |c|
248
+ hsh[c] = :avg
249
+ end
250
+ gfooter('Group Average', hsh)
251
+ end
252
+
253
+ def min_footer(*cols)
254
+ hsh = {}
255
+ cols.each do |c|
256
+ hsh[c] = :min
257
+ end
258
+ footer('Minimum', hsh)
259
+ end
260
+
261
+ def min_gfooter(*cols)
262
+ hsh = {}
263
+ cols.each do |c|
264
+ hsh[c] = :min
265
+ end
266
+ gfooter('Group Minimum', hsh)
267
+ end
268
+
269
+ def max_footer(*cols)
270
+ hsh = {}
271
+ cols.each do |c|
272
+ hsh[c] = :max
273
+ end
274
+ footer('Maximum', hsh)
275
+ end
276
+
277
+ def max_gfooter(*cols)
278
+ hsh = {}
279
+ cols.each do |c|
280
+ hsh[c] = :max
281
+ end
282
+ gfooter('Group Maximum', hsh)
283
+ end
284
+
285
+ ############################################################################
286
+ # Formatting methods
287
+ ############################################################################
288
+
289
+ # Define formats for all locations
290
+ def format(**fmts)
291
+ [:header, :bfirst, :gfirst, :body, :footer, :gfooter].each do |loc|
292
+ format_for(loc, fmts)
293
+ end
294
+ end
295
+
296
+ # Define a format for the given location, :header, :body, :footer, :gfooter
297
+ # (the group footers), :bfirst (the first row in the table body), or :gfirst
298
+ # (the first rows in group bodies). Formats are specified with hash
299
+ # arguments where the keys are either (1) the name of a table column in
300
+ # symbol form, or (2) the name of a column type, i.e., :string, :numeric, or
301
+ # :datetime, :boolean, or :nil (for empty cells or untyped columns). The
302
+ # value given for the hash arguments should be strings that contain
303
+ # "instructions" on how elements of that column, or that type are to be
304
+ # formatted on output. Formatting instructions for a column name take
305
+ # precedence over those specified by type. And more specific locations take
306
+ # precedence over less specific ones. For example, the first line of a table
307
+ # is part of :body, :gfirst, and :bfirst, but since its identity as the
308
+ # first row of the table is the most specific (there is only one of those,
309
+ # there may be many rows that qualify as :gfirst, and even more that qualify
310
+ # as :body rows). For purposes of formatting, all headers are considered of
311
+ # the :string type. All empty cells are considered to be of the :nilclass
312
+ # type. All other cells have the type of the column to which they belong,
313
+ # including all cells in group or table footers.
314
+ def format_for(location, **fmts)
315
+ unless LOCATIONS.include?(location)
316
+ raise ArgumentError, "unknown format location '#{location}'"
317
+ end
318
+ valid_keys = table.headers + [:string, :numeric, :datetime, :boolean, :nil]
319
+ invalid_keys = (fmts.keys - valid_keys).uniq
320
+ unless invalid_keys.empty?
321
+ msg = "invalid #{location} column or type: #{invalid_keys.join(',')}"
322
+ raise ArgumentError, msg
323
+ end
324
+ @format_at[location] ||= {}
325
+ table.headers.each do |h|
326
+ # Default formatting hash
327
+ format_h = default_format.dup
328
+
329
+ # Merge in type-based formatting
330
+ typ = table.type(h).as_sym
331
+ parse_typ_method_name = 'parse_' + typ.to_s + '_fmt'
332
+ if location == :header
333
+ # Treat header as string type
334
+ if fmts.keys.include?(:string)
335
+ str_fmt = parse_string_fmt(fmts[:string])
336
+ format_h = format_h.merge(str_fmt)
337
+ end
338
+ else
339
+ # Use column type for other locations
340
+ if fmts.keys.include?(typ)
341
+ typ_fmt = send(parse_typ_method_name, fmts[typ])
342
+ format_h = format_h.merge(typ_fmt)
343
+ end
344
+ if fmts.keys.include?(:string)
345
+ typ_fmt = parse_string_fmt(fmts[:string])
346
+ format_h = format_h.merge(typ_fmt)
347
+ end
348
+ if fmts.keys.include?(:nil)
349
+ typ_fmt = parse_nil_fmt(fmts[:nil]).first
350
+ format_h = format_h.merge(typ_fmt)
351
+ end
352
+ end
353
+
354
+ # Merge in column-based formatting
355
+ if fmts[h]
356
+ col_fmt = send(parse_typ_method_name, fmts[h])
357
+ format_h = format_h.merge(col_fmt)
358
+ end
359
+
360
+ # Convert to struct
361
+ format_at[location][h] = OpenStruct.new(format_h)
362
+ end
363
+ self
364
+ end
365
+
366
+ ###############################################################################
367
+ # Parsing and validation routines
368
+ ###############################################################################
369
+
370
+ private
371
+
372
+ # Return a hash that reflects the formatting instructions given in the
373
+ # string fmt. Raise an error if it contains invalid formatting instructions.
374
+ # If fmt contains conflicting instructions, say C and L, there is no
375
+ # guarantee which will win, but it will not be considered an error to do so.
376
+ def parse_string_fmt(fmt)
377
+ format, fmt = parse_str_fmt(fmt)
378
+ unless fmt.blank?
379
+ raise ArgumentError, "unrecognized string formatting instructions '#{fmt}'"
380
+ end
381
+ format
382
+ end
383
+
384
+ # Utility method that extracts string instructions and returns a hash for
385
+ # of the instructions and the unconsumed part of the instruction string.
386
+ # This is called to cull string-based instructions from a formatting string
387
+ # intended for other types, such as numeric, etc.
388
+ def parse_str_fmt(fmt)
389
+ # We parse the more complex formatting constructs first, and after each
390
+ # parse, we remove the matched construct from fmt. At the end, any
391
+ # remaining characters in fmt should be invalid.
392
+ fmt_hash = {}
393
+ if fmt =~ /c\[([-_a-zA-Z]+)\]/
394
+ fmt_hash[:color] = $1
395
+ fmt = fmt.sub($&, '')
396
+ end
397
+ if fmt =~ /u/
398
+ fmt_hash[:case] = :lower
399
+ fmt = fmt.sub($&, '')
400
+ end
401
+ if fmt =~ /U/
402
+ fmt_hash[:case] = :upper
403
+ fmt = fmt.sub($&, '')
404
+ end
405
+ if fmt =~ /t/
406
+ fmt_hash[:case] = :title
407
+ fmt = fmt.sub($&, '')
408
+ end
409
+ if fmt =~ /B/
410
+ fmt_hash[:bold] = true
411
+ fmt = fmt.sub($&, '')
412
+ end
413
+ if fmt =~ /I/
414
+ fmt_hash[:italic] = true
415
+ fmt = fmt.sub($&, '')
416
+ end
417
+ if fmt =~ /R/
418
+ fmt_hash[:alignment] = :right
419
+ fmt = fmt.sub($&, '')
420
+ end
421
+ if fmt =~ /C/
422
+ fmt_hash[:alignment] = :center
423
+ fmt = fmt.sub($&, '')
424
+ end
425
+ if fmt =~ /L/
426
+ fmt_hash[:alignment] = :left
427
+ fmt = fmt.sub($&, '')
428
+ end
429
+ [fmt_hash, fmt]
430
+ end
431
+
432
+ # Utility method that extracts nil instructions and returns a hash of the
433
+ # instructions and the unconsumed part of the instruction string. This is
434
+ # called to cull nil-based instructions from a formatting string intended
435
+ # for other types, such as numeric, etc.
436
+ def parse_nil_fmt(fmt)
437
+ # We parse the more complex formatting constructs first, and after each
438
+ # parse, we remove the matched construct from fmt. At the end, any
439
+ # remaining characters in fmt should be invalid.
440
+ fmt_hash = {}
441
+ if fmt =~ /n\[\s*([^\]]*)\s*\]/
442
+ fmt_hash[:nil_text] = $1.clean
443
+ fmt = fmt.sub($&, '')
444
+ end
445
+ [fmt_hash, fmt]
446
+ end
447
+
448
+ # Return a hash that reflects the numeric or string formatting instructions
449
+ # given in the string fmt. Raise an error if it contains invalid formatting
450
+ # instructions. If fmt contains conflicting instructions, there is no
451
+ # guarantee which will win, but it will not be considered an error to do so.
452
+ def parse_numeric_fmt(fmt)
453
+ # We parse the more complex formatting constructs first, and after each
454
+ # parse, we remove the matched construct from fmt. At the end, any
455
+ # remaining characters in fmt should be invalid.
456
+ fmt_hash, fmt = parse_str_fmt(fmt)
457
+ fmt = fmt.gsub(/\s+/, '')
458
+ if fmt =~ /(\d+).(\d+)/
459
+ fmt_hash[:pre_digits] = $1.to_i
460
+ fmt_hash[:post_digits] = $2.to_i
461
+ fmt = fmt.sub($&, '')
462
+ end
463
+ if fmt =~ /,/
464
+ fmt_hash[:commas] = true
465
+ fmt = fmt.sub($&, '')
466
+ end
467
+ if fmt =~ /\$/
468
+ fmt_hash[:currency] = true
469
+ fmt = fmt.sub($&, '')
470
+ end
471
+ if fmt =~ /H/
472
+ fmt_hash[:hms] = true
473
+ fmt = fmt.sub($&, '')
474
+ end
475
+ unless fmt.blank?
476
+ raise ArgumentError, "unrecognized numeric formatting instructions '#{fmt}'"
477
+ end
478
+ fmt_hash
479
+ end
480
+
481
+ # Return a hash that reflects the datetime or string formatting instructions
482
+ # given in the string fmt. Raise an error if it contains invalid formatting
483
+ # instructions. If fmt contains conflicting instructions, there is no
484
+ # guarantee which will win, but it will not be considered an error to do so.
485
+ def parse_datetime_fmt(fmt)
486
+ # We parse the more complex formatting constructs first, and after each
487
+ # parse, we remove the matched construct from fmt. At the end, any
488
+ # remaining characters in fmt should be invalid.
489
+ fmt_hash, fmt = parse_str_fmt(fmt)
490
+ fmt = fmt.gsub(/\s+/, '')
491
+ if fmt =~ /d\[([^\]]*)\]/
492
+ fmt_hash[:date_fmt] = $1
493
+ fmt = fmt.sub($&, '')
494
+ end
495
+ if fmt =~ /D\[([^\]]*)\]/
496
+ fmt_hash[:date_fmt] = $1
497
+ fmt = fmt.sub($&, '')
498
+ end
499
+ unless fmt.blank?
500
+ raise ArgumentError, "unrecognized datetime formatting instructions '#{fmt}'"
501
+ end
502
+ fmt_hash
503
+ end
504
+
505
+ # Return a hash that reflects the boolean or string formatting instructions
506
+ # given in the string fmt. Raise an error if it contains invalid formatting
507
+ # instructions. If fmt contains conflicting instructions, there is no
508
+ # guarantee which will win, but it will not be considered an error to do so.
509
+ def parse_boolean_fmt(fmt)
510
+ # We parse the more complex formatting constructs first, and after each
511
+ # parse, we remove the matched construct from fmt. At the end, any
512
+ # remaining characters in fmt should be invalid.
513
+ fmt_hash, fmt = parse_str_fmt(fmt)
514
+ if fmt =~ /b\[\s*([^\],]*),([^\]]*)\s*\]/
515
+ fmt_hash[:true_text] = $1.clean
516
+ fmt_hash[:false_text] = $2.clean
517
+ fmt = fmt.sub($&, '')
518
+ end
519
+ # Since true_text, false_text and nil_text may want to have internal
520
+ # spaces, defer removing extraneous spaces until after they are parsed.
521
+ fmt = fmt.gsub(/\s+/, '')
522
+ if fmt =~ /c\[([-_a-zA-Z]+),([-_a-zA-Z]+)\]/
523
+ fmt_hash[:true_color] = $1
524
+ fmt_hash[:false_color] = $2
525
+ fmt = fmt.sub($&, '')
526
+ end
527
+ if fmt =~ /Y/
528
+ fmt_hash[:true_text] = 'Y'
529
+ fmt_hash[:false_text] = 'N'
530
+ fmt = fmt.sub($&, '')
531
+ end
532
+ if fmt =~ /T/
533
+ fmt_hash[:true_text] = 'T'
534
+ fmt_hash[:false_text] = 'F'
535
+ fmt = fmt.sub($&, '')
536
+ end
537
+ if fmt =~ /X/
538
+ fmt_hash[:true_text] = 'X'
539
+ fmt_hash[:false_text] = ''
540
+ fmt = fmt.sub($&, '')
541
+ end
542
+ unless fmt.blank?
543
+ raise ArgumentError, "unrecognized boolean formatting instructions '#{fmt}'"
544
+ end
545
+ fmt_hash
546
+ end
547
+
548
+ ###############################################################################
549
+ # Applying formatting
550
+ ###############################################################################
551
+
552
+ public
553
+
554
+ # Convert a value to a string based on the instructions in istruct,
555
+ # depending on the type of val.
556
+ def format_cell(val, istruct, width = nil)
557
+ case val
558
+ when Numeric
559
+ str = format_numeric(val, istruct)
560
+ format_string(str, istruct, width)
561
+ when DateTime, Date
562
+ str = format_datetime(val, istruct)
563
+ format_string(str, istruct, width)
564
+ when TrueClass, FalseClass
565
+ str = format_boolean(val, istruct)
566
+ format_string(str, istruct, width)
567
+ when NilClass
568
+ str = istruct.nil_text
569
+ format_string(str, istruct, width)
570
+ when String
571
+ format_string(val, istruct, width)
572
+ else
573
+ raise ArgumentError,
574
+ "cannot format value '#{val}' of class #{val.class}"
575
+ end
576
+ end
577
+
578
+ private
579
+
580
+ # Convert a boolean to a string according to instructions in istruct, which
581
+ # is assumed to be the result of parsing a formatting instruction string as
582
+ # above. Only device-independent formatting is done here. Device dependent
583
+ # formatting (e.g., color) can be done in a subclass of Formatter by
584
+ # specializing this method.
585
+ def format_boolean(val, istruct)
586
+ return istruct.nil_text if val.nil?
587
+ val ? istruct.true_text : istruct.false_text
588
+ end
589
+
590
+ # Convert a datetime to a string according to instructions in istruct, which
591
+ # is assumed to be the result of parsing a formatting instruction string as
592
+ # above. Only device-independent formatting is done here. Device dependent
593
+ # formatting (e.g., color) can be done in a subclass of Formatter by
594
+ # specializing this method.
595
+ def format_datetime(val, istruct)
596
+ return istruct.nil_text if val.nil?
597
+ if val.to_date == val
598
+ # It is a Date, with no time component.
599
+ val.strftime(istruct.date_fmt)
600
+ else
601
+ val.strftime(istruct.datetime_fmt)
602
+ end
603
+ end
604
+
605
+ # Convert a numeric to a string according to instructions in istruct, which
606
+ # is assumed to be the result of parsing a formatting instruction string as
607
+ # above. Only device-independent formatting is done here. Device dependent
608
+ # formatting (e.g., color) can be done in a subclass of Formatter by
609
+ # specializing this method.
610
+ def format_numeric(val, istruct)
611
+ return istruct.nil_text if val.nil?
612
+ val = val.round(istruct.post_digits) if istruct.post_digits >= 0
613
+ if istruct.hms
614
+ result = val.secs_to_hms
615
+ istruct.commas = false
616
+ elsif istruct.currency
617
+ prec = istruct.post_digits == -1 ? 2 : istruct.post_digits
618
+ delim = istruct.commas ? ',' : ''
619
+ result = val.to_s(:currency, precision: prec, delimiter: delim,
620
+ unit: currency_symbol)
621
+ istruct.commas = false
622
+ elsif istruct.pre_digits.positive?
623
+ if val.whole?
624
+ # No fractional part, ignore post_digits
625
+ result = sprintf("%0#{istruct.pre_digits}d", val)
626
+ elsif istruct.post_digits >= 0
627
+ # There's a fractional part and pre_digits. sprintf width includes
628
+ # space for fractional part and decimal point, so add pre, post, and 1
629
+ # to get the proper sprintf width.
630
+ wid = istruct.pre_digits + 1 + istruct.post_digits
631
+ result = sprintf("%0#{wid}.#{istruct.post_digits}f", val)
632
+ else
633
+ val = val.round(0)
634
+ result = sprintf("%0#{istruct.pre_digits}d", val)
635
+ end
636
+ elsif istruct.post_digits >= 0
637
+ # Round to post_digits but no padding of whole number, pad fraction with
638
+ # trailing zeroes.
639
+ result = sprintf("%.#{istruct.post_digits}f", val)
640
+ else
641
+ result = val.to_s
642
+ end
643
+ if istruct.commas
644
+ # Commify the whole number part if not done already.
645
+ result = result.commify
646
+ end
647
+ result
648
+ end
649
+
650
+ # Apply non-device-dependent string formatting instructions.
651
+ def format_string(val, istruct, width = nil)
652
+ val = istruct.nil_text if val.nil?
653
+ val =
654
+ case istruct.case
655
+ when :lower
656
+ val.downcase
657
+ when :upper
658
+ val.upcase
659
+ when :title
660
+ val.entitle
661
+ when :none
662
+ val
663
+ end
664
+ if width && aligned?
665
+ pad = width - width(val)
666
+ case istruct.alignment
667
+ when :left
668
+ val += ' ' * pad
669
+ when :right
670
+ val = ' ' * pad + val
671
+ when :center
672
+ lpad = pad / 2 + (pad.odd? ? 1 : 0)
673
+ rpad = pad / 2
674
+ val = ' ' * lpad + val + ' ' * rpad
675
+ else
676
+ val = val
677
+ end
678
+ end
679
+ val
680
+ end
681
+
682
+ ###############################################################################
683
+ # Output routines
684
+ ###############################################################################
685
+
686
+ public
687
+
688
+ def output
689
+ # Build the output as an array of row hashes, with the hashes keyed on the
690
+ # new header string and the values the string-formatted cells. Each group
691
+ # boundary, gfooter, and footer is marked by inserting a nil in the result
692
+ # array at that point.
693
+ formatted_headers = build_formatted_headers
694
+ new_rows = build_formatted_body
695
+ new_rows += build_formatted_footers
696
+
697
+ # Make a second pass over the formatted cells to add spacing according to
698
+ # the alignment instruction if this is a Formatter that performs its own
699
+ # alignment.
700
+ if aligned?
701
+ widths = width_map(formatted_headers, new_rows)
702
+ table.headers.each do |h|
703
+ formatted_headers[h] =
704
+ format_string(formatted_headers[h], format_at[:header][h], widths[h])
705
+ end
706
+ aligned_rows = []
707
+ new_rows.each do |loc_row|
708
+ if loc_row.nil?
709
+ aligned_rows << nil
710
+ next
711
+ end
712
+ loc, row = *loc_row
713
+ aligned_row = {}
714
+ row.each_pair do |h, val|
715
+ aligned_row[h] = format_string(val, format_at[loc][h], widths[h])
716
+ end
717
+ aligned_rows << [loc, aligned_row]
718
+ end
719
+ new_rows = aligned_rows
720
+ end
721
+
722
+ # Now that the contents of the output table cells have been computed and
723
+ # alignment applied, we can actually construct the table using the methods
724
+ # for constructing table parts, pre_table, etc. We expect that these will
725
+ # be overridden by subclasses of Formatter for specific output targets. In
726
+ # any event, the result is a single string representing the table in the
727
+ # syntax of the output target.
728
+ result = ''
729
+ result += pre_table
730
+ if include_header_row?
731
+ result += pre_header(widths)
732
+ result += pre_row
733
+ cells = []
734
+ formatted_headers.each_pair do |h, v|
735
+ cells << pre_cell(h) + quote_cell(v) + post_cell
736
+ end
737
+ result += cells.join(inter_cell)
738
+ result += post_row
739
+ result += post_header(widths)
740
+ end
741
+ new_rows.each do |loc_row|
742
+ result += hline(widths) if loc_row.nil?
743
+ next if loc_row.nil?
744
+ _loc, row = *loc_row
745
+ result += pre_row
746
+ cells = []
747
+ row.each_pair do |h, v|
748
+ cells << pre_cell(h) + quote_cell(v) + post_cell
749
+ end
750
+ result += cells.join(inter_cell)
751
+ result += post_row
752
+ end
753
+ result += post_footers(widths)
754
+ result += post_table
755
+ evaluate? ? eval(result) : result
756
+ end
757
+
758
+ private
759
+
760
+ # Return a hash mapping the table's headers to their formatted versions. If
761
+ # a hash of column widths is given, perform alignment within the given field
762
+ # widths.
763
+ def build_formatted_headers(widths = {})
764
+ map = {}
765
+ table.headers.each do |h|
766
+ map[h] = format_string(h.as_string, format_at[:header][h], widths[h])
767
+ end
768
+ map
769
+ end
770
+
771
+ # Return an array of two-element arrays, with the first element of the
772
+ # inner array being the location of the row and the second element being a
773
+ # hash, using the table's headers as keys and the formatted cells as the
774
+ # values. Add formatted group footers along the way.
775
+ def build_formatted_body
776
+ new_rows = []
777
+ tbl_row_k = 0
778
+ table.groups.each_with_index do |grp, grp_k|
779
+ # Mark the beginning of a group if this is the first group after the
780
+ # header or the second or later group.
781
+ new_rows << nil if include_header_row? || grp_k.positive?
782
+ # Compute group body
783
+ grp_col = {}
784
+ grp.each_with_index do |row, grp_row_k|
785
+ new_row = {}
786
+ location =
787
+ if tbl_row_k.zero?
788
+ :bfirst
789
+ elsif grp_row_k.zero?
790
+ :gfirst
791
+ else
792
+ :body
793
+ end
794
+ table.headers.each do |h|
795
+ grp_col[h] ||= Column.new(header: h)
796
+ grp_col[h] << row[h]
797
+ new_row[h] = format_cell(row[h], format_at[location][h])
798
+ end
799
+ new_rows << [location, new_row]
800
+ tbl_row_k += 1
801
+ end
802
+ # Compute group footers
803
+ gfooters.each_pair do |label, gfooter|
804
+ # Mark the beginning of a group footer
805
+ new_rows << nil
806
+ gfoot_row = {}
807
+ first_h = nil
808
+ grp_col.each_pair do |h, col|
809
+ first_h ||= h
810
+ gfoot_row[h] =
811
+ if gfooter[h]
812
+ format_cell(col.send(gfooter[h]), format_at[:gfooter][h])
813
+ else
814
+ ''
815
+ end
816
+ end
817
+ if gfoot_row[first_h].blank?
818
+ gfoot_row[first_h] = format_string(label, format_at[:gfooter][first_h])
819
+ end
820
+ new_rows << [:gfooter, gfoot_row]
821
+ end
822
+ end
823
+ new_rows
824
+ end
825
+
826
+ def build_formatted_footers
827
+ new_rows = []
828
+ # Done with body, compute the table footers.
829
+ footers.each_pair do |label, footer|
830
+ # Mark the beginning of a footer
831
+ new_rows << nil
832
+ foot_row = {}
833
+ first_h = nil
834
+ table.columns.each do |col|
835
+ h = col.header
836
+ first_h ||= h
837
+ foot_row[h] =
838
+ if footer[h]
839
+ format_cell(col.send(footer[h]), format_at[:footer][h])
840
+ else
841
+ ''
842
+ end
843
+ end
844
+ # Put the label in the first column of footer unless it has been
845
+ # formatted as part of footer.
846
+ if foot_row[first_h].blank?
847
+ foot_row[first_h] = format_string(label, format_at[:footer][first_h])
848
+ end
849
+ new_rows << [:footer, foot_row]
850
+ end
851
+ new_rows
852
+ end
853
+
854
+ # Return a hash of the maximum widths of all the given headers and rows.
855
+ def width_map(formatted_headers, rows)
856
+ widths = {}
857
+ formatted_headers.each_pair do |h, v|
858
+ widths[h] ||= 0
859
+ widths[h] = [widths[h], width(v)].max
860
+ end
861
+ rows.each do |loc_row|
862
+ next if loc_row.nil?
863
+ _loc, row = *loc_row
864
+ row.each_pair do |h, v|
865
+ widths[h] ||= 0
866
+ widths[h] = [widths[h], width(v)].max
867
+ end
868
+ end
869
+ widths
870
+ end
871
+
872
+ # Does this Formatter require a second pass over the cells to align the
873
+ # columns according to the alignment formatting instruction to the width of
874
+ # the widest cell in each column?
875
+ def aligned?
876
+ false
877
+ end
878
+
879
+ # Should the string result of output be evaluated to form a ruby data
880
+ # structure? For example, AoaFormatter wants to return an array of arrays of
881
+ # strings, so it should build a ruby expression to do that, then have it
882
+ # eval'ed.
883
+ def evaluate?
884
+ false
885
+ end
886
+
887
+ # Compute the width of the string as displayed, taking into account the
888
+ # characteristics of the target device. For example, a colored string
889
+ # should not include in the width terminal control characters that simply
890
+ # change the color without occupying any space. Thus, this method must be
891
+ # overridden in a subclass if a simple character count does not reflect the
892
+ # width as displayed.
893
+ def width(str)
894
+ str.length
895
+ end
896
+
897
+ def pre_table
898
+ ''
899
+ end
900
+
901
+ def post_table
902
+ ''
903
+ end
904
+
905
+ def include_header_row?
906
+ true
907
+ end
908
+
909
+ def pre_header(_widths)
910
+ ''
911
+ end
912
+
913
+ def post_header(_widths)
914
+ ''
915
+ end
916
+
917
+ def pre_row
918
+ ''
919
+ end
920
+
921
+ def pre_cell(_h)
922
+ ''
923
+ end
924
+
925
+ def quote_cell(v)
926
+ v
927
+ end
928
+
929
+ def post_cell
930
+ ''
931
+ end
932
+
933
+ def inter_cell
934
+ '|'
935
+ end
936
+
937
+ def post_row
938
+ "\n"
939
+ end
940
+
941
+ def hline(_widths)
942
+ ''
943
+ end
944
+
945
+ def pre_group
946
+ ''
947
+ end
948
+
949
+ def post_group
950
+ ''
951
+ end
952
+
953
+ def pre_gfoot
954
+ ''
955
+ end
956
+
957
+ def post_gfoot
958
+ ''
959
+ end
960
+
961
+ def pre_foot
962
+ ''
963
+ end
964
+
965
+ def post_foot
966
+ ''
967
+ end
968
+
969
+ def post_footers(_widths)
970
+ ''
971
+ end
972
+ end
973
+ end