tabulo 2.6.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.
- checksums.yaml +7 -0
- data/.ackrc +10 -0
- data/.gitignore +12 -0
- data/.rdoc_options +22 -0
- data/.rspec +2 -0
- data/.travis.yml +10 -0
- data/.yardopts +2 -0
- data/CHANGELOG.md +249 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +21 -0
- data/README.md +1353 -0
- data/Rakefile +17 -0
- data/VERSION +1 -0
- data/_config.yml +1 -0
- data/assets/social_media_preview/table.png +0 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/lib/tabulo.rb +10 -0
- data/lib/tabulo/border.rb +164 -0
- data/lib/tabulo/cell.rb +155 -0
- data/lib/tabulo/cell_data.rb +14 -0
- data/lib/tabulo/column.rb +114 -0
- data/lib/tabulo/deprecation.rb +33 -0
- data/lib/tabulo/exceptions.rb +12 -0
- data/lib/tabulo/row.rb +51 -0
- data/lib/tabulo/table.rb +763 -0
- data/lib/tabulo/util.rb +45 -0
- data/lib/tabulo/version.rb +3 -0
- data/tabulo.gemspec +43 -0
- metadata +227 -0
data/Rakefile
ADDED
@@ -0,0 +1,17 @@
|
|
1
|
+
require "bundler/gem_tasks"
|
2
|
+
require "rspec/core/rake_task"
|
3
|
+
require "rake-version"
|
4
|
+
require "yard"
|
5
|
+
|
6
|
+
RSpec::Core::RakeTask.new(:spec)
|
7
|
+
|
8
|
+
task default: :spec
|
9
|
+
|
10
|
+
RakeVersion::Tasks.new do |v|
|
11
|
+
v.copy "lib/tabulo/version.rb"
|
12
|
+
v.copy "README.md", all: true
|
13
|
+
end
|
14
|
+
|
15
|
+
YARD::Rake::YardocTask.new do |t|
|
16
|
+
t.options = ["--markup-provider=redcarpet", "--markup=markdown"]
|
17
|
+
end
|
data/VERSION
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
2.6.0
|
data/_config.yml
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
theme: jekyll-theme-dinky
|
Binary file
|
data/bin/console
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "bundler/setup"
|
4
|
+
require "tabulo"
|
5
|
+
|
6
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
7
|
+
# with your gem easier. You can also use a different console, if you like.
|
8
|
+
|
9
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
10
|
+
# require "pry"
|
11
|
+
# Pry.start
|
12
|
+
|
13
|
+
require "irb"
|
14
|
+
IRB.start
|
data/bin/setup
ADDED
data/lib/tabulo.rb
ADDED
@@ -0,0 +1,10 @@
|
|
1
|
+
require "tabulo/deprecation"
|
2
|
+
require "tabulo/exceptions"
|
3
|
+
require "tabulo/util"
|
4
|
+
require "tabulo/table"
|
5
|
+
require "tabulo/version"
|
6
|
+
require "tabulo/row"
|
7
|
+
require "tabulo/cell_data"
|
8
|
+
require "tabulo/cell"
|
9
|
+
require "tabulo/column"
|
10
|
+
require "tabulo/border"
|
@@ -0,0 +1,164 @@
|
|
1
|
+
module Tabulo
|
2
|
+
|
3
|
+
# @!visibility private
|
4
|
+
class Border
|
5
|
+
|
6
|
+
Style = Struct.new(
|
7
|
+
:corner_top_left, :corner_top_right, :corner_bottom_right, :corner_bottom_left,
|
8
|
+
:edge_top, :edge_right, :edge_bottom, :edge_left,
|
9
|
+
:tee_top, :tee_right, :tee_bottom, :tee_left,
|
10
|
+
:divider_vertical, :divider_horizontal, :intersection)
|
11
|
+
|
12
|
+
STYLES = {
|
13
|
+
ascii:
|
14
|
+
Style.new(
|
15
|
+
"+", "+", "+", "+",
|
16
|
+
"-", "|", "-", "|",
|
17
|
+
"+", "+", "+", "+",
|
18
|
+
"|", "-", "+",
|
19
|
+
),
|
20
|
+
classic:
|
21
|
+
Style.new(
|
22
|
+
"+", "+", "", "",
|
23
|
+
"-", "|", "", "|",
|
24
|
+
"+", "+", "", "+",
|
25
|
+
"|", "-", "+",
|
26
|
+
),
|
27
|
+
reduced_ascii:
|
28
|
+
Style.new(
|
29
|
+
"", "", "", "",
|
30
|
+
"-", "", "-", "",
|
31
|
+
" ", "", " ", "",
|
32
|
+
" ", "-", " ",
|
33
|
+
),
|
34
|
+
reduced_modern:
|
35
|
+
Style.new(
|
36
|
+
"", "", "", "",
|
37
|
+
"─", "", "─", "",
|
38
|
+
" ", "", " ", "",
|
39
|
+
" ", "─", " ",
|
40
|
+
),
|
41
|
+
markdown:
|
42
|
+
Style.new(
|
43
|
+
"", "", "", "",
|
44
|
+
"", "|", "", "|",
|
45
|
+
"", "|", "", "|",
|
46
|
+
"|", "-", "|",
|
47
|
+
),
|
48
|
+
modern:
|
49
|
+
Style.new(
|
50
|
+
"┌", "┐", "┘", "└",
|
51
|
+
"─", "│", "─", "│",
|
52
|
+
"┬", "┤", "┴", "├",
|
53
|
+
"│", "─", "┼",
|
54
|
+
),
|
55
|
+
blank:
|
56
|
+
Style.new(
|
57
|
+
"", "", "", "",
|
58
|
+
"", "", "", "",
|
59
|
+
"", "", "", "",
|
60
|
+
"", "", "",
|
61
|
+
),
|
62
|
+
}
|
63
|
+
|
64
|
+
# @!visibility private
|
65
|
+
def self.from(initializer, styler = nil)
|
66
|
+
new(**options(initializer).merge(styler: styler))
|
67
|
+
end
|
68
|
+
|
69
|
+
# @!visibility private
|
70
|
+
def horizontal_rule(column_widths, position = :bottom)
|
71
|
+
left, center, right, segment =
|
72
|
+
case position
|
73
|
+
when :title_top
|
74
|
+
[@corner_top_left, @edge_top, @corner_top_right, @edge_top]
|
75
|
+
when :title_bottom
|
76
|
+
[@tee_left, @tee_top, @tee_right, @edge_top]
|
77
|
+
when :top
|
78
|
+
[@corner_top_left, @tee_top, @corner_top_right, @edge_top]
|
79
|
+
when :middle
|
80
|
+
[@tee_left, @intersection, @tee_right, @divider_horizontal]
|
81
|
+
when :bottom
|
82
|
+
[@corner_bottom_left, @tee_bottom, @corner_bottom_right, @edge_bottom]
|
83
|
+
end
|
84
|
+
segments = column_widths.map { |width| segment * width }
|
85
|
+
|
86
|
+
# Prevent weird bottom edge of title if segments empty but right/left not empty, as in
|
87
|
+
# Markdown border.
|
88
|
+
left = right = "" if segments.all?(&:empty?)
|
89
|
+
|
90
|
+
style("#{left}#{segments.join(center)}#{right}")
|
91
|
+
end
|
92
|
+
|
93
|
+
# @!visibility private
|
94
|
+
def join_cell_contents(cells)
|
95
|
+
styled_divider_vertical = style(@divider_vertical)
|
96
|
+
styled_edge_left = style(@edge_left)
|
97
|
+
styled_edge_right = style(@edge_right)
|
98
|
+
styled_edge_left + cells.join(styled_divider_vertical) + styled_edge_right
|
99
|
+
end
|
100
|
+
|
101
|
+
private
|
102
|
+
|
103
|
+
def self.options(kind)
|
104
|
+
opts = STYLES[kind]
|
105
|
+
return opts.to_h if opts
|
106
|
+
raise InvalidBorderError
|
107
|
+
end
|
108
|
+
|
109
|
+
# @param [nil, #to_proc] styler (nil) A lambda or other callable object taking
|
110
|
+
# a single parameter, representing a section of the table's borders (which for this purpose
|
111
|
+
# include any horizontal and vertical lines inside the table), and returning a string.
|
112
|
+
# If passed <tt>nil</tt>, then no additional styling will be applied to borders. If passed a
|
113
|
+
# callable, then that callable will be called for each border section, with the
|
114
|
+
# resulting string rendered in place of that border. The extra width of the string returned by the
|
115
|
+
# {styler} is not taken into consideration by the internal table rendering calculations
|
116
|
+
# Thus it can be used to apply ANSI escape codes to border characters, to colour the borders
|
117
|
+
# for example, without breaking the table formatting.
|
118
|
+
# @return [Border] a new {Border}
|
119
|
+
def initialize(
|
120
|
+
corner_top_left: "",
|
121
|
+
corner_top_right: "",
|
122
|
+
corner_bottom_right: "",
|
123
|
+
corner_bottom_left: "",
|
124
|
+
edge_top: "",
|
125
|
+
edge_right: "",
|
126
|
+
edge_bottom: "",
|
127
|
+
edge_left: "",
|
128
|
+
tee_top: "",
|
129
|
+
tee_right: "",
|
130
|
+
tee_bottom: "",
|
131
|
+
tee_left: "",
|
132
|
+
divider_vertical: "",
|
133
|
+
divider_horizontal: "",
|
134
|
+
intersection: "",
|
135
|
+
styler: nil)
|
136
|
+
|
137
|
+
@corner_top_left = corner_top_left
|
138
|
+
@corner_top_right = corner_top_right
|
139
|
+
@corner_bottom_right = corner_bottom_right
|
140
|
+
@corner_bottom_left = corner_bottom_left
|
141
|
+
|
142
|
+
@edge_top = edge_top
|
143
|
+
@edge_right = edge_right
|
144
|
+
@edge_bottom = edge_bottom
|
145
|
+
@edge_left = edge_left
|
146
|
+
|
147
|
+
@tee_top = tee_top
|
148
|
+
@tee_right = tee_right
|
149
|
+
@tee_bottom = tee_bottom
|
150
|
+
@tee_left = tee_left
|
151
|
+
|
152
|
+
@divider_vertical = divider_vertical
|
153
|
+
@divider_horizontal = divider_horizontal
|
154
|
+
|
155
|
+
@intersection = intersection
|
156
|
+
|
157
|
+
@styler = styler
|
158
|
+
end
|
159
|
+
|
160
|
+
def style(s)
|
161
|
+
(@styler && !s.empty?) ? @styler.call(s) : s
|
162
|
+
end
|
163
|
+
end
|
164
|
+
end
|
data/lib/tabulo/cell.rb
ADDED
@@ -0,0 +1,155 @@
|
|
1
|
+
require "unicode/display_width"
|
2
|
+
|
3
|
+
module Tabulo
|
4
|
+
|
5
|
+
# Represents a single cell within the body of a {Table}.
|
6
|
+
class Cell
|
7
|
+
|
8
|
+
# @return the underlying value for this Cell
|
9
|
+
attr_reader :value
|
10
|
+
|
11
|
+
# @!visibility private
|
12
|
+
def initialize(
|
13
|
+
alignment:,
|
14
|
+
cell_data:,
|
15
|
+
formatter:,
|
16
|
+
left_padding:,
|
17
|
+
padding_character:,
|
18
|
+
right_padding:,
|
19
|
+
styler:,
|
20
|
+
truncation_indicator:,
|
21
|
+
value:,
|
22
|
+
width:)
|
23
|
+
|
24
|
+
@alignment = alignment
|
25
|
+
@cell_data = cell_data
|
26
|
+
@formatter = formatter
|
27
|
+
@left_padding = left_padding
|
28
|
+
@padding_character = padding_character
|
29
|
+
@right_padding = right_padding
|
30
|
+
@styler = styler
|
31
|
+
@truncation_indicator = truncation_indicator
|
32
|
+
@value = value
|
33
|
+
@width = width
|
34
|
+
end
|
35
|
+
|
36
|
+
# @!visibility private
|
37
|
+
def height
|
38
|
+
subcells.size
|
39
|
+
end
|
40
|
+
|
41
|
+
# @!visibility private
|
42
|
+
def padded_truncated_subcells(target_height)
|
43
|
+
total_padding_amount = @left_padding + @right_padding
|
44
|
+
truncated = (height > target_height)
|
45
|
+
(0...target_height).map do |subcell_index|
|
46
|
+
append_truncator = (truncated && (total_padding_amount != 0) && (subcell_index + 1 == target_height))
|
47
|
+
padded_subcell(subcell_index, append_truncator)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
# @return [String] the content of the Cell, after applying the formatter for this Column (but
|
52
|
+
# without applying any wrapping or the styler).
|
53
|
+
def formatted_content
|
54
|
+
@formatted_content ||= apply_formatter
|
55
|
+
end
|
56
|
+
|
57
|
+
private
|
58
|
+
|
59
|
+
def apply_formatter
|
60
|
+
if @formatter.arity == 2
|
61
|
+
@formatter.call(@value, @cell_data)
|
62
|
+
else
|
63
|
+
@formatter.call(@value)
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
def apply_styler(content, line_index)
|
68
|
+
case @styler.arity
|
69
|
+
when 4
|
70
|
+
@styler.call(@value, content, @cell_data, line_index)
|
71
|
+
when 3
|
72
|
+
@styler.call(@value, content, @cell_data)
|
73
|
+
else
|
74
|
+
@styler.call(@value, content)
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
def subcells
|
79
|
+
@subcells ||= calculate_subcells
|
80
|
+
end
|
81
|
+
|
82
|
+
def padded_subcell(subcell_index, append_truncator)
|
83
|
+
lpad = @padding_character * @left_padding
|
84
|
+
rpad =
|
85
|
+
if append_truncator
|
86
|
+
styled_truncation_indicator(subcell_index) + padding(@right_padding - 1)
|
87
|
+
else
|
88
|
+
padding(@right_padding)
|
89
|
+
end
|
90
|
+
inner = subcell_index < height ? subcells[subcell_index] : padding(@width)
|
91
|
+
"#{lpad}#{inner}#{rpad}"
|
92
|
+
end
|
93
|
+
|
94
|
+
def padding(amount)
|
95
|
+
@padding_character * amount
|
96
|
+
end
|
97
|
+
|
98
|
+
def styled_truncation_indicator(line_index)
|
99
|
+
apply_styler(@truncation_indicator, line_index)
|
100
|
+
end
|
101
|
+
|
102
|
+
def calculate_subcells
|
103
|
+
line_index = 0
|
104
|
+
formatted_content.split($/, -1).flat_map do |substr|
|
105
|
+
subsubcells, subsubcell, subsubcell_width = [], String.new(""), 0
|
106
|
+
|
107
|
+
substr.scan(/\X/).each do |grapheme_cluster|
|
108
|
+
grapheme_cluster_width = Unicode::DisplayWidth.of(grapheme_cluster)
|
109
|
+
if subsubcell_width + grapheme_cluster_width > @width
|
110
|
+
subsubcells << style_and_align_cell_content(subsubcell, line_index)
|
111
|
+
subsubcell_width = 0
|
112
|
+
subsubcell.clear
|
113
|
+
line_index += 1
|
114
|
+
end
|
115
|
+
|
116
|
+
subsubcell << grapheme_cluster
|
117
|
+
subsubcell_width += grapheme_cluster_width
|
118
|
+
end
|
119
|
+
|
120
|
+
subsubcells << style_and_align_cell_content(subsubcell, line_index)
|
121
|
+
line_index += 1
|
122
|
+
subsubcells
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
def style_and_align_cell_content(content, line_index)
|
127
|
+
padding = Util.max(@width - Unicode::DisplayWidth.of(content), 0)
|
128
|
+
left_padding, right_padding =
|
129
|
+
case real_alignment
|
130
|
+
when :center
|
131
|
+
half_padding = padding / 2
|
132
|
+
[padding - half_padding, half_padding]
|
133
|
+
when :left
|
134
|
+
[0, padding]
|
135
|
+
when :right
|
136
|
+
[padding, 0]
|
137
|
+
end
|
138
|
+
|
139
|
+
"#{' ' * left_padding}#{apply_styler(content, line_index)}#{' ' * right_padding}"
|
140
|
+
end
|
141
|
+
|
142
|
+
def real_alignment
|
143
|
+
return @alignment unless @alignment == :auto
|
144
|
+
|
145
|
+
case @value
|
146
|
+
when Numeric
|
147
|
+
:right
|
148
|
+
when TrueClass, FalseClass
|
149
|
+
:center
|
150
|
+
else
|
151
|
+
:left
|
152
|
+
end
|
153
|
+
end
|
154
|
+
end
|
155
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
module Tabulo
|
2
|
+
|
3
|
+
# Contains information about a particular {Cell} in the {Table}.
|
4
|
+
#
|
5
|
+
# @attr source [Object] The member of this {Cell}'s {Table}'s underlying enumerable from which
|
6
|
+
# this {Cell}'s {Row} was derived.
|
7
|
+
# @attr row_index [Integer] The positional index of the {Cell}'s {Row}. The topmost {Row} of the
|
8
|
+
# {Table} has index 0, the next has index 1, etc.. The header row(s) are not counted for the purpose
|
9
|
+
# of this numbering.
|
10
|
+
# @attr column_index [Integer] The positional index of the {Cell}'s {Column}. The leftmost {Column}
|
11
|
+
# of the {Table} has index 0, the next has index 1, etc..
|
12
|
+
CellData = Struct.new(:source, :row_index, :column_index)
|
13
|
+
|
14
|
+
end
|
@@ -0,0 +1,114 @@
|
|
1
|
+
module Tabulo
|
2
|
+
|
3
|
+
# @!visibility private
|
4
|
+
class Column
|
5
|
+
|
6
|
+
attr_accessor :width
|
7
|
+
attr_reader :header
|
8
|
+
attr_reader :index
|
9
|
+
attr_reader :left_padding
|
10
|
+
attr_reader :right_padding
|
11
|
+
|
12
|
+
def initialize(
|
13
|
+
align_body:,
|
14
|
+
align_header:,
|
15
|
+
extractor:,
|
16
|
+
formatter:,
|
17
|
+
header:,
|
18
|
+
header_styler:,
|
19
|
+
index:,
|
20
|
+
left_padding:,
|
21
|
+
padding_character:,
|
22
|
+
right_padding:,
|
23
|
+
styler:,
|
24
|
+
truncation_indicator:,
|
25
|
+
width:)
|
26
|
+
|
27
|
+
@align_body = align_body
|
28
|
+
@align_header = align_header
|
29
|
+
@extractor = extractor
|
30
|
+
@formatter = formatter
|
31
|
+
@header = header
|
32
|
+
@index = index
|
33
|
+
@left_padding = left_padding
|
34
|
+
@right_padding = right_padding
|
35
|
+
|
36
|
+
@header_styler =
|
37
|
+
if header_styler
|
38
|
+
case header_styler.arity
|
39
|
+
when 3
|
40
|
+
-> (_, str, cell_data, line_index) { header_styler.call(str, cell_data.column_index, line_index) }
|
41
|
+
when 2
|
42
|
+
-> (_, str, cell_data) { header_styler.call(str, cell_data.column_index) }
|
43
|
+
else
|
44
|
+
-> (_, str) { header_styler.call(str) }
|
45
|
+
end
|
46
|
+
else
|
47
|
+
-> (_, str) { str }
|
48
|
+
end
|
49
|
+
|
50
|
+
@padding_character = padding_character
|
51
|
+
@styler = styler || -> (_, s) { s }
|
52
|
+
@truncation_indicator = truncation_indicator
|
53
|
+
@width = width
|
54
|
+
end
|
55
|
+
|
56
|
+
def header_cell
|
57
|
+
if @header_styler.arity >= 3
|
58
|
+
cell_data = CellData.new(nil, nil, @index)
|
59
|
+
end
|
60
|
+
Cell.new(
|
61
|
+
alignment: @align_header,
|
62
|
+
cell_data: cell_data,
|
63
|
+
formatter: -> (s) { s },
|
64
|
+
left_padding: @left_padding,
|
65
|
+
padding_character: @padding_character,
|
66
|
+
right_padding: @right_padding,
|
67
|
+
styler: @header_styler,
|
68
|
+
truncation_indicator: @truncation_indicator,
|
69
|
+
value: @header,
|
70
|
+
width: @width,
|
71
|
+
)
|
72
|
+
end
|
73
|
+
|
74
|
+
def body_cell(source, row_index:, column_index:)
|
75
|
+
if body_cell_data_required?
|
76
|
+
cell_data = CellData.new(source, row_index, @index)
|
77
|
+
end
|
78
|
+
Cell.new(
|
79
|
+
alignment: @align_body,
|
80
|
+
cell_data: cell_data,
|
81
|
+
formatter: @formatter,
|
82
|
+
left_padding: @left_padding,
|
83
|
+
padding_character: @padding_character,
|
84
|
+
right_padding: @right_padding,
|
85
|
+
styler: @styler,
|
86
|
+
truncation_indicator: @truncation_indicator,
|
87
|
+
value: body_cell_value(source, row_index: row_index, column_index: column_index),
|
88
|
+
width: @width,
|
89
|
+
)
|
90
|
+
end
|
91
|
+
|
92
|
+
def body_cell_value(source, row_index:, column_index:)
|
93
|
+
if @extractor.arity == 2
|
94
|
+
@extractor.call(source, row_index)
|
95
|
+
else
|
96
|
+
@extractor.call(source)
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
def padded_width
|
101
|
+
width + total_padding
|
102
|
+
end
|
103
|
+
|
104
|
+
def total_padding
|
105
|
+
@left_padding + @right_padding
|
106
|
+
end
|
107
|
+
|
108
|
+
private
|
109
|
+
|
110
|
+
def body_cell_data_required?
|
111
|
+
@cell_data_required ||= (@styler.arity == 3 || @formatter.arity == 2)
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|