fat_core 1.7.1 → 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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