fat_core 1.7.1 → 2.0.0

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