Tablo

Build Status

Tablo generates formatted text tables from 2D arrays*.

Tablo is a port of Matt Harvey's Tabulo Ruby gem to the Crystal Language. Most Tabulo features are available, as a significant part of the Ruby source code has been merely copied, with almost no modification.

However, some substantial modifications and additions were required to meet Crystal's strict typing rules, especially for conversion of input data to the internal data structure. Indeed, where Tabulo accepts any type of Enumerable data, Tablo only accepts a 2D array as argument, further converted to Tablo::DataType, a 2D array of CellType, the elementary data type union Tablo works on, before processing.

Finally, new features have been added with respect to formatting: UTF characters, preset or at the user's choice, for drawing borders, and optional row or column separators

* Here, 2D is used as a writing simplification for "array of scalar data arrays" defining a rectangular matrix

Main features

Most features of Tabulo are found in Tablo

Installation

Add this to your application's shard.yml:

dependencies:
  tablo:
    github: hutou/tablo

Usage

require "tablo"

Example data set

Most of the examples below are built upon the following 2D array, excerpt from my imagination !

    data = [
    # Name         kind     Sex      Age     Weight       Initial     Annual
    #                            (years)       (Kg)          cost   expenses
    ["Charlie",   "Dog",   'M',        7,      37.4,       420.50,       695],
    ["Max",       "Cat",   'M',       12,       4.2,       575.32,       790],
    ["Simba",     "Cat",   'M',        5,       3.8,       498.70,       720],
    ["Coco",      "Dog",   'F',        8,      13.9,       276.36,       632],
    ["Ruby",      "Dog",   'F',        6,      15.7,       320.95,       543],
    ]

Tutorial

As a first step towards using Tablo, let's define a basic set of statements for displaying each element of the data array.

12: table = Tablo::Table.new(data) do |t|
13:   t.add_column("Name") { |n| n[0] }
14:   t.add_column("Kind") { |n| n[1] }
15:   t.add_column("Sex") { |n| n[2] }
16:   t.add_column("Age") { |n| n[3] }
17:   t.add_column("Weight") { |n| n[4] }
18:   t.add_column("Initial\ncost") { |n| n[5] }
19:   t.add_column("Average\nannual\nexpenses") { |n| n[6] }
20: end
21: puts table

Numbered lines extracted from examples/readme1.cr

+--------------+--------------+--------------+--------------+--------------+--------------+--------------+
| Name         | Kind         | Sex          |          Age |       Weight |      Initial |      Average |
|              |              |              |              |              |         cost |       annual |
|              |              |              |              |              |              |     expenses |
+--------------+--------------+--------------+--------------+--------------+--------------+--------------+
| Charlie      | Dog          | M            |            7 |         37.0 |        420.5 |          695 |
| Max          | Cat          | M            |           12 |          4.2 |       575.32 |          790 |
| Simba        | Cat          | M            |            5 |          3.8 |        498.7 |          720 |
| Coco         | Dog          | F            |            8 |         13.9 |       276.36 |          632 |
| Ruby         | Dog          | F            |            6 |         15.7 |       320.95 |          543 |
+--------------+--------------+--------------+--------------+--------------+--------------+--------------+

So, using the Table class is simply feeding it with data and adding some columns with header and extracting proc.

In this first example, several defaults are used for table initialization and columns definition

Now, let's do something more elaborate and more fancy !

Here are the modified lines :

12: table = Tablo::Table.new(data, connectors: Tablo::CONNECTORS_SINGLE_ROUNDED) do |t|
13:   t.add_column("Name", width: 8) { |n| n[0].as(String).upcase }
14:   t.add_column("Kind", align_header: Tablo::Justify::Center, align_body: Tablo::Justify::Center, width: 4) { |n| n[1] }
15:   t.add_column("Sex", align_header: Tablo::Justify::Center, align_body: Tablo::Justify::Center, width: 4) { |n| n[2] }
18:   t.add_column("Initial\ncost", formatter: ->(x : Tablo::CellType) { "%.2f" % x }) { |n| n[5] }

file : examples/readme2.cr

╭──────────┬──────┬──────┬──────────────┬──────────────┬──────────────┬──────────────╮
│ Name     │ Kind │  Sex │          Age │       Weight │      Initial │      Average │
│          │      │      │              │              │         cost │       annual │
│          │      │      │              │              │              │     expenses │
├──────────┼──────┼──────┼──────────────┼──────────────┼──────────────┼──────────────┤
│ CHARLIE  │  Dog │   M  │            7 │         37.0 │       420.50 │          695 │
│ MAX      │  Cat │   M  │           12 │          4.2 │       575.32 │          790 │
│ SIMBA    │  Cat │   M  │            5 │          3.8 │       498.70 │          720 │
│ COCO     │  Dog │   F  │            8 │         13.9 │       276.36 │          632 │
│ RUBY     │  Dog │   F  │            6 │         15.7 │       320.95 │          543 │
╰──────────┴──────┴──────┴──────────────┴──────────────┴──────────────┴──────────────╯

Several columns may use the same data element, and more than one data element may be used in a column !

Let's compute the total cost of each pet, by replacing line 19 with :

19:   t.add_column("Total\nCost", formatter: ->(x : Tablo::CellType) { "%.2f" % x }) { |n| n[3].as(Number) * n[6].as(Number) + n[5].as(Number) }

file : examples/readme3.cr

╭──────────┬──────┬──────┬──────────────┬──────────────┬──────────────┬──────────────╮
│ Name     │ Kind │  Sex │          Age │       Weight │      Initial │        Total │
│          │      │      │              │              │         cost │         cost │
├──────────┼──────┼──────┼──────────────┼──────────────┼──────────────┼──────────────┤
│ CHARLIE  │  Dog │   M  │            7 │         37.0 │       420.50 │      5285.50 │
│ MAX      │  Cat │   M  │           12 │          4.2 │       575.32 │     10055.32 │
│ SIMBA    │  Cat │   M  │            5 │          3.8 │       498.70 │      4098.70 │
│ COCO     │  Dog │   F  │            8 │         13.9 │       276.36 │      5332.36 │
│ RUBY     │  Dog │   F  │            6 │         15.7 │       320.95 │      3578.95 │
╰──────────┴──────┴──────┴──────────────┴──────────────┴──────────────┴──────────────╯

Suppose we want to associate age and weight in the same column, with special formatting. We could replace lines 16 and 17 with the following :

16:   t.add_column("Age : weight") { |n| "%3d : %6.1f" % [n[3], n[4]] }

Note that we cannot use the formatter proc here, as it expects a CellType value, not an Array.

file : examples/readme4.cr

╭──────────┬──────┬──────┬──────────────┬──────────────┬──────────────╮
│ Name     │ Kind │  Sex │ Age : weight │      Initial │        Total │
│          │      │      │              │         cost │         cost │
├──────────┼──────┼──────┼──────────────┼──────────────┼──────────────┤
│ CHARLIE  │  Dog │   M  │   7 :   37.0 │       420.50 │      5285.50 │
│ MAX      │  Cat │   M  │  12 :    4.2 │       575.32 │     10055.32 │
│ SIMBA    │  Cat │   M  │   5 :    3.8 │       498.70 │      4098.70 │
│ COCO     │  Dog │   F  │   8 :   13.9 │       276.36 │      5332.36 │
│ RUBY     │  Dog │   F  │   6 :   15.7 │       320.95 │      3578.95 │
╰──────────┴──────┴──────┴──────────────┴──────────────┴──────────────╯

And if we want double line borders horizontally and single line borders vertically, excluding top and bottom borders, we could replace line 12 with

12: table = Tablo::Table.new(data, connectors: Tablo::CONNECTORS_SINGLE_DOUBLE, style: "lc,mc,rc,ml") do |t|

file : examples/readme5.cr

│ Name     │ Kind │  Sex │ Age : weight │      Initial │        Total │
│          │      │      │              │         cost │         cost │
╞══════════╪══════╪══════╪══════════════╪══════════════╪══════════════╡
│ CHARLIE  │  Dog │   M  │   7 :   37.0 │       420.50 │      5285.50 │
│ MAX      │  Cat │   M  │  12 :    4.2 │       575.32 │     10055.32 │
│ SIMBA    │  Cat │   M  │   5 :    3.8 │       498.70 │      4098.70 │
│ COCO     │  Dog │   F  │   8 :   13.9 │       276.36 │      5332.36 │
│ RUBY     │  Dog │   F  │   6 :   15.7 │       320.95 │      3578.95 │

Main classes and methods reference

The Tablo::Table class

data input

As shown in the examples, if we except the block used to add columns, a Table can be created with only one mandatory argument : the data structure to work on. This data structure needs to be a 2D array of some types included in Celltype, which are defined as

alias CellType = Bool | Char | Int::Signed | Int::Unsigned | Float32 | Float64 | String | Symbol
alias DataType = Array(Array(CellType))

A DataException is raised if given data is not a 2D array or cannot be converted for some reason.

Default formatting parameters
t.add_column("Initial\ncost",
    formatter: ->(x : Tablo::CellType) { "%.2f" % x },
    styler : ->(s : Tablo::CellType) { "#{s.colorize(:red)}" })
    { |n| n[5] }

In the previous example, some headers are 2 lines high. Here is the effect of limiting their height to 1.

12: table = Tablo::Table.new(data, connectors: Tablo::CONNECTORS_SINGLE_DOUBLE, style: "lc,mc,rc,ml", wrap_header_cells_to: 1) do |t|

file : examples/readme6.cr

│ Name     │ Kind │  Sex │ Age : weight │      Initial~│        Total~│
╞══════════╪══════╪══════╪══════════════╪══════════════╪══════════════╡
│ CHARLIE  │  Dog │   M  │   7 :   37.0 │       420.50 │      5285.50 │
│ MAX      │  Cat │   M  │  12 :    4.2 │       575.32 │     10055.32 │
│ SIMBA    │  Cat │   M  │   5 :    3.8 │       498.70 │      4098.70 │
│ COCO     │  Dog │   F  │   8 :   13.9 │       276.36 │      5332.36 │
│ RUBY     │  Dog │   F  │   6 :   15.7 │       320.95 │      3578.95 │

The truncation character is displayed in the header right padding area of the last 2 columns.

When dealing with data containing many rows, it could be interesting to repeat the headers every n rows. Here is a first example, with a factor repetition of 3

12: table = Tablo::Table.new(data, connectors: Tablo::CONNECTORS_LIGHT_HEAVY, header_frequency: 3) do |t|

file : examples/readme7.cr

┍━━━━━━━━━━┯━━━━━━┯━━━━━━┯━━━━━━━━━━━━━━┯━━━━━━━━━━━━━━┯━━━━━━━━━━━━━━┑
│ Name     │ Kind │  Sex │ Age : weight │      Initial │        Total │
│          │      │      │              │         cost │         Cost │
┝━━━━━━━━━━┿━━━━━━┿━━━━━━┿━━━━━━━━━━━━━━┿━━━━━━━━━━━━━━┿━━━━━━━━━━━━━━┥
│ CHARLIE  │  Dog │   M  │   7 :   37.0 │       420.50 │      5285.50 │
│ MAX      │  Cat │   M  │  12 :    4.2 │       575.32 │     10055.32 │
│ SIMBA    │  Cat │   M  │   5 :    3.8 │       498.70 │      4098.70 │
┝━━━━━━━━━━┿━━━━━━┿━━━━━━┿━━━━━━━━━━━━━━┿━━━━━━━━━━━━━━┿━━━━━━━━━━━━━━┥
│ Name     │ Kind │  Sex │ Age : weight │      Initial │        Total │
│          │      │      │              │         cost │         Cost │
┝━━━━━━━━━━┿━━━━━━┿━━━━━━┿━━━━━━━━━━━━━━┿━━━━━━━━━━━━━━┿━━━━━━━━━━━━━━┥
│ COCO     │  Dog │   F  │   8 :   13.9 │       276.36 │      5332.36 │
│ RUBY     │  Dog │   F  │   6 :   15.7 │       320.95 │      3578.95 │
┕━━━━━━━━━━┷━━━━━━┷━━━━━━┷━━━━━━━━━━━━━━┷━━━━━━━━━━━━━━┷━━━━━━━━━━━━━━┙

and again, with the same factor, but negative

12: table = Tablo::Table.new(data, connectors: Tablo::CONNECTORS_HEAVY_LIGHT, header_frequency: -3) do |t|

file : examples/readme8.cr

┎──────────┰──────┰──────┰──────────────┰──────────────┰──────────────┒
┃ Name     ┃ Kind ┃  Sex ┃ Age : weight ┃      Initial ┃        Total ┃
┃          ┃      ┃      ┃              ┃         cost ┃         Cost ┃
┠──────────╂──────╂──────╂──────────────╂──────────────╂──────────────┨
┃ CHARLIE  ┃  Dog ┃   M  ┃   7 :   37.0 ┃       420.50 ┃      5285.50 ┃
┃ MAX      ┃  Cat ┃   M  ┃  12 :    4.2 ┃       575.32 ┃     10055.32 ┃
┃ SIMBA    ┃  Cat ┃   M  ┃   5 :    3.8 ┃       498.70 ┃      4098.70 ┃
┖──────────┸──────┸──────┸──────────────┸──────────────┸──────────────┚
┎──────────┰──────┰──────┰──────────────┰──────────────┰──────────────┒
┃ Name     ┃ Kind ┃  Sex ┃ Age : weight ┃      Initial ┃        Total ┃
┃          ┃      ┃      ┃              ┃         cost ┃         Cost ┃
┠──────────╂──────╂──────╂──────────────╂──────────────╂──────────────┨
┃ COCO     ┃  Dog ┃   F  ┃   8 :   13.9 ┃       276.36 ┃      5332.36 ┃
┃ RUBY     ┃  Dog ┃   F  ┃   6 :   15.7 ┃       320.95 ┃      3578.95 ┃
┖──────────┸──────┸──────┸──────────────┸──────────────┸──────────────┚

As of release 0.9.4, formatting has been enhanced by a new method : Tablo.fpjust, which allows alignment on decimal point for floating point values, after removing non significant digits.

To illustrate, running the program below :

require "tablo"

data = [
  # Name        Initial   Initial   Initial   Initial   Initial
  #                cost      cost      cost      cost      cost
  ["Charlie",    420.50,   420.50,   420.50,   420.50,   420.50],
  ["Max",        575.32,   575.32,   575.32,   575.32,   575.32],
  ["Simba",      498.00,   498.00,   498.00,   498.00,   498.00],
  ["Coco",       276.36,   276.36,   276.36,   276.36,   276.36],
  ["Ruby",       320.95,   320.95,   320.95,   320.95,   320.95],
  ["Freecat",      0.0,      0.0,      0.0,      0.0,      0.0 ],
]

Tablo.fpjust(data, 1, 5, nil) # Params: data array, column, decimals, mode
Tablo.fpjust(data, 2, 4, 0)
Tablo.fpjust(data, 3, 3, 1)
Tablo.fpjust(data, 4, 2, 2)
Tablo.fpjust(data, 5, 1, 3)
table = Tablo::Table.new(data) do |t|
  t.add_column("Name") { |n| n[0] }
  t.add_column("Initial\ncost\nmode=nil\ndec=5") { |n| n[1] }
  t.add_column("Initial\ncost\nmode=0\ndec=4") { |n| n[2] }
  t.add_column("Initial\ncost\nmode=1\ndec=3") { |n| n[3] }
  t.add_column("Initial\ncost\nmode=2\ndec=2") { |n| n[4] }
  t.add_column("Initial\ncost\nmode=3\ndec=1") { |n| n[5] }
end
table.shrinkwrap!
puts table

file : examples/readme11.cr

produces the following output :


+---------+-----------+---------+---------+---------+---------+
| Name    | Initial   | Initial | Initial | Initial | Initial |
|         | cost      | cost    | cost    | cost    | cost    |
|         | mode=nil  | mode=0  | mode=1  | mode=2  | mode=3  |
|         | dec=5     | dec=4   | dec=3   | dec=2   | dec=1   |
+---------+-----------+---------+---------+---------+---------+
| Charlie | 420.50000 | 420.5   | 420.5   | 420.5   | 420.5   |
| Max     | 575.32000 | 575.32  | 575.32  | 575.32  | 575.3   |
| Simba   | 498.00000 | 498.    | 498     | 498     | 498.0   |
| Coco    | 276.36000 | 276.36  | 276.36  | 276.36  | 276.4   |
| Ruby    | 320.95000 | 320.95  | 320.95  | 320.95  | 320.9   |
| Freecat |   0.00000 |   0.    |   0     |         |   0.0   |
+---------+-----------+---------+---------+---------+---------+

Caution: Notice that this method alters the input data array, turning a floating point column into a string column.

Table methods

There are essentially 4 methods useful for the user : add_column, each, horizontal_rule and shrinkwrap!

add_column

This is the method used for table definition. Its parameters are :

In addition, add_column requires a block defining how array element is extracted, and possibly converted.

horizontal_rule

When specific formatting is desirable, the horizontal_rule method come handy. It accepts one argument, the type of line to be displayed : Top, Middle or Bottom. See an example of use for the next method each

each

The each method is useful when one wants to fine tune output. Instead of a mere

puts table

one can write the table one row at a time, so that it becomes possible to define very specific output, for example by inserting an horizontal rule between each row. Lets try it with the previous example, replacing the puts table in line 21 with

21: table.each_with_index do |row, i|
22:   puts table.horizontal_rule(Tablo::TLine::Mid) if i > 0 && (i % 3) != 0 && table.style =~ /ML/i
23:   puts row
24: end
25  puts table.horizontal_rule(Tablo::TLine::Bot) if table.style =~ /BL/i

file : examples/readme9.cr

┎──────────┰──────┰──────┰──────────────┰──────────────┰──────────────┒
┃ Name     ┃ Kind ┃  Sex ┃ Age : weight ┃      Initial ┃        Total ┃
┃          ┃      ┃      ┃              ┃         cost ┃         Cost ┃
┠──────────╂──────╂──────╂──────────────╂──────────────╂──────────────┨
┃ CHARLIE  ┃  Dog ┃   M  ┃   7 :   37.0 ┃       420.50 ┃      5285.50 ┃
┠──────────╂──────╂──────╂──────────────╂──────────────╂──────────────┨
┃ MAX      ┃  Cat ┃   M  ┃  12 :    4.2 ┃       575.32 ┃     10055.32 ┃
┠──────────╂──────╂──────╂──────────────╂──────────────╂──────────────┨
┃ SIMBA    ┃  Cat ┃   M  ┃   5 :    3.8 ┃       498.70 ┃      4098.70 ┃
┖──────────┸──────┸──────┸──────────────┸──────────────┸──────────────┚
┎──────────┰──────┰──────┰──────────────┰──────────────┰──────────────┒
┃ Name     ┃ Kind ┃  Sex ┃ Age : weight ┃      Initial ┃        Total ┃
┃          ┃      ┃      ┃              ┃         cost ┃         Cost ┃
┠──────────╂──────╂──────╂──────────────╂──────────────╂──────────────┨
┃ COCO     ┃  Dog ┃   F  ┃   8 :   13.9 ┃       276.36 ┃      5332.36 ┃
┠──────────╂──────╂──────╂──────────────╂──────────────╂──────────────┨
┃ RUBY     ┃  Dog ┃   F  ┃   6 :   15.7 ┃       320.95 ┃      3578.95 ┃
┖──────────┸──────┸──────┸──────────────┸──────────────┸──────────────┚

Note that, when using the each (or each_with_index) method on the table, it is now up to the user to manage the display of horizontal rules.

shrinkwrap!

And now, the magic ! If we insert the line table.shrinkwrap! before line 21, all columns have their width reduced to the minimum !

Take care, however, because the width of columns is then adjusted to their content, regardless of their width (fixed or default): so columns may be narrowed or widened!

If table width gets too wide, there is fortunately a workaround : just pass an argument to shrinkwrap! to limit the total width of the table (or, if table width is too small, pass this argument as a negative value to force a minimum table width).

21: table.shrinkwrap!
22: table.each_with_index do |row, i|
23:   puts table.horizontal_rule(Tablo::TLine::Mid) if i > 0 && (i % 3) != 0 && table.style =~ /ML/i
24:   puts row
25: end
26  puts table.horizontal_rule(Tablo::TLine::Bot) if table.style =~ /BL/i

file : examples/readme10.cr

┎─────────┰──────┰─────┰──────────────┰─────────┰──────────┒
┃ Name    ┃ Kind ┃ Sex ┃ Age : weight ┃ Initial ┃    Total ┃
┃         ┃      ┃     ┃              ┃    cost ┃     Cost ┃
┠─────────╂──────╂─────╂──────────────╂─────────╂──────────┨
┃ CHARLIE ┃  Dog ┃  M  ┃   7 :   37.0 ┃  420.50 ┃  5285.50 ┃
┠─────────╂──────╂─────╂──────────────╂─────────╂──────────┨
┃ MAX     ┃  Cat ┃  M  ┃  12 :    4.2 ┃  575.32 ┃ 10055.32 ┃
┠─────────╂──────╂─────╂──────────────╂─────────╂──────────┨
┃ SIMBA   ┃  Cat ┃  M  ┃   5 :    3.8 ┃  498.70 ┃  4098.70 ┃
┖─────────┸──────┸─────┸──────────────┸─────────┸──────────┚
┎─────────┰──────┰─────┰──────────────┰─────────┰──────────┒
┃ Name    ┃ Kind ┃ Sex ┃ Age : weight ┃ Initial ┃    Total ┃
┃         ┃      ┃     ┃              ┃    cost ┃     Cost ┃
┠─────────╂──────╂─────╂──────────────╂─────────╂──────────┨
┃ COCO    ┃  Dog ┃  F  ┃   8 :   13.9 ┃  276.36 ┃  5332.36 ┃
┠─────────╂──────╂─────╂──────────────╂─────────╂──────────┨
┃ RUBY    ┃  Dog ┃  F  ┃   6 :   15.7 ┃  320.95 ┃  3578.95 ┃
┖─────────┸──────┸─────┸──────────────┸─────────┸──────────┚

The Tablo::Row class

Generally, class Row is not meant to be used directly, but its methods can be used for specific needs.

Row methods
each

Iterates over the row cells, as extracted from source (unformatted unless formatting occurs in the extractor proc)

to_s

Returns a string being an "ASCII" graphical representation of the Row, including any column headers that appear just above it in the Table (depending on where this Row is in the Table and how the Table was configured with respect to header frequency).

to_h

Returns a Hash representation of the Row, with column labels acting as keys and the calculated cell values (before formatting) providing the values.

Contributing

  1. Fork it (https://github.com/your-github-user/tablo/fork)
  2. Create your feature branch (git checkout -b my-new-feature)
  3. Commit your changes (git commit -am 'Add some feature')
  4. Push to the branch (git push origin my-new-feature)
  5. Create a new Pull Request