tessella 0.3.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/LICENSE +21 -0
- data/README.md +900 -0
- data/doc/README.md +74 -0
- data/doc/bitfield.md +123 -0
- data/doc/charts.md +1370 -0
- data/doc/check.rb +75 -0
- data/doc/cli.md +483 -0
- data/doc/composition.md +599 -0
- data/doc/getting-started.md +121 -0
- data/doc/glyphs.md +134 -0
- data/doc/images.md +325 -0
- data/doc/library.md +239 -0
- data/doc/lint.md +270 -0
- data/doc/meter.md +210 -0
- data/doc/rule.md +51 -0
- data/doc/seq.md +169 -0
- data/doc/sparkline.md +63 -0
- data/doc/table.md +347 -0
- data/doc/targets.md +222 -0
- data/doc/text.md +84 -0
- data/doc/tree.md +108 -0
- data/doc/wave.md +122 -0
- data/examples/README.md +103 -0
- data/examples/ages.txt +9 -0
- data/examples/bad.csv +3 -0
- data/examples/boxes.txt +3 -0
- data/examples/broken.txt +4 -0
- data/examples/calibration.rb +12 -0
- data/examples/cities.csv +4 -0
- data/examples/cjk.rb +13 -0
- data/examples/comment.rb +9 -0
- data/examples/comment.sh +13 -0
- data/examples/cpu.txt +60 -0
- data/examples/dashboard.sh +25 -0
- data/examples/datasheet.rb +16 -0
- data/examples/disc.pgm +0 -0
- data/examples/disks.sh +18 -0
- data/examples/disks.txt +5 -0
- data/examples/dose.txt +7 -0
- data/examples/drift.txt +4 -0
- data/examples/ethernet.txt +3 -0
- data/examples/figure.c +5 -0
- data/examples/fit.rb +16 -0
- data/examples/floors.rb +21 -0
- data/examples/gallery.sh +20 -0
- data/examples/gauges.sh +31 -0
- data/examples/gradient.rb +16 -0
- data/examples/growth.txt +6 -0
- data/examples/hosts.csv +4 -0
- data/examples/icmp.txt +3 -0
- data/examples/ipv4.txt +12 -0
- data/examples/kinds.csv +4 -0
- data/examples/langs.csv +4 -0
- data/examples/lint.rb +16 -0
- data/examples/load.rb +14 -0
- data/examples/login.txt +10 -0
- data/examples/neg.csv +4 -0
- data/examples/normal.txt +200 -0
- data/examples/note.txt +2 -0
- data/examples/notes-fixed.md +17 -0
- data/examples/notes.md +17 -0
- data/examples/ohlc.txt +7 -0
- data/examples/os.csv +10 -0
- data/examples/outline.txt +8 -0
- data/examples/plot.txt +5 -0
- data/examples/refusals.rb +17 -0
- data/examples/report.sh +21 -0
- data/examples/rfc.sh +22 -0
- data/examples/samples.txt +7 -0
- data/examples/shapes.pgm +0 -0
- data/examples/sizes.tsv +3 -0
- data/examples/spi.txt +6 -0
- data/examples/status.sh +23 -0
- data/examples/stem.txt +12 -0
- data/examples/table-drift.txt +4 -0
- data/examples/temps.txt +31 -0
- data/examples/trig.sh +13 -0
- data/examples/trig.txt +102 -0
- data/examples/week.txt +8 -0
- data/examples/wide.txt +2 -0
- data/exe/tessella +6 -0
- data/lib/tessella/axis.rb +71 -0
- data/lib/tessella/bitfield.rb +194 -0
- data/lib/tessella/block.rb +56 -0
- data/lib/tessella/borders.rb +85 -0
- data/lib/tessella/canvas/area.rb +155 -0
- data/lib/tessella/canvas/ascii.rb +120 -0
- data/lib/tessella/canvas/block.rb +40 -0
- data/lib/tessella/canvas/braille.rb +30 -0
- data/lib/tessella/canvas/clip.rb +49 -0
- data/lib/tessella/canvas/density.rb +51 -0
- data/lib/tessella/canvas/dot.rb +27 -0
- data/lib/tessella/canvas/eighths.rb +43 -0
- data/lib/tessella/canvas/half.rb +51 -0
- data/lib/tessella/canvas/sextant.rb +36 -0
- data/lib/tessella/canvas.rb +168 -0
- data/lib/tessella/cell.rb +10 -0
- data/lib/tessella/chart.rb +211 -0
- data/lib/tessella/check.rb +52 -0
- data/lib/tessella/cli/bitfield.rb +121 -0
- data/lib/tessella/cli/chart.rb +615 -0
- data/lib/tessella/cli/chart_candle.rb +36 -0
- data/lib/tessella/cli/chart_heatmap.rb +21 -0
- data/lib/tessella/cli/chart_help.rb +85 -0
- data/lib/tessella/cli/chart_pie.rb +38 -0
- data/lib/tessella/cli/compose.rb +156 -0
- data/lib/tessella/cli/compose_options.rb +259 -0
- data/lib/tessella/cli/glyphs.rb +97 -0
- data/lib/tessella/cli/image.rb +250 -0
- data/lib/tessella/cli/input.rb +44 -0
- data/lib/tessella/cli/iso_time.rb +28 -0
- data/lib/tessella/cli/lint.rb +102 -0
- data/lib/tessella/cli/lint_options.rb +99 -0
- data/lib/tessella/cli/main.rb +121 -0
- data/lib/tessella/cli/meter.rb +80 -0
- data/lib/tessella/cli/rule.rb +57 -0
- data/lib/tessella/cli/seq.rb +117 -0
- data/lib/tessella/cli/sparkline.rb +72 -0
- data/lib/tessella/cli/table.rb +167 -0
- data/lib/tessella/cli/text.rb +78 -0
- data/lib/tessella/cli/tree.rb +100 -0
- data/lib/tessella/cli/wave.rb +148 -0
- data/lib/tessella/cli.rb +36 -0
- data/lib/tessella/color.rb +106 -0
- data/lib/tessella/compose.rb +243 -0
- data/lib/tessella/compose_fill.rb +176 -0
- data/lib/tessella/compose_grid.rb +36 -0
- data/lib/tessella/compose_shadow.rb +41 -0
- data/lib/tessella/error.rb +13 -0
- data/lib/tessella/frame.rb +170 -0
- data/lib/tessella/glyphs.rb +106 -0
- data/lib/tessella/image.rb +454 -0
- data/lib/tessella/image_octant.rb +46 -0
- data/lib/tessella/image_petscii.rb +116 -0
- data/lib/tessella/kinds/bar.rb +344 -0
- data/lib/tessella/kinds/box.rb +213 -0
- data/lib/tessella/kinds/box_upright.rb +110 -0
- data/lib/tessella/kinds/candlestick.rb +164 -0
- data/lib/tessella/kinds/columns.rb +259 -0
- data/lib/tessella/kinds/date_axis.rb +102 -0
- data/lib/tessella/kinds/error_bars.rb +71 -0
- data/lib/tessella/kinds/heatmap.rb +130 -0
- data/lib/tessella/kinds/line.rb +173 -0
- data/lib/tessella/kinds/multibar.rb +98 -0
- data/lib/tessella/kinds/multibar_columns.rb +130 -0
- data/lib/tessella/kinds/multibar_fine.rb +45 -0
- data/lib/tessella/kinds/multibar_rows.rb +93 -0
- data/lib/tessella/kinds/pie.rb +123 -0
- data/lib/tessella/kinds/pie_rim.rb +78 -0
- data/lib/tessella/kinds/sideways.rb +62 -0
- data/lib/tessella/kinds/stem.rb +134 -0
- data/lib/tessella/kinds/threshold.rb +73 -0
- data/lib/tessella/legend.rb +31 -0
- data/lib/tessella/lint/alphabet.rb +56 -0
- data/lib/tessella/lint/drift.rb +82 -0
- data/lib/tessella/lint/fences.rb +51 -0
- data/lib/tessella/lint/geometry.rb +120 -0
- data/lib/tessella/lint/geometry_checks.rb +224 -0
- data/lib/tessella/lint/lines.rb +115 -0
- data/lib/tessella/lint/mixing.rb +61 -0
- data/lib/tessella/lint/runner.rb +73 -0
- data/lib/tessella/lint/shadows.rb +120 -0
- data/lib/tessella/lint/target_rows.rb +66 -0
- data/lib/tessella/lint.rb +158 -0
- data/lib/tessella/meter.rb +133 -0
- data/lib/tessella/meter_upright.rb +43 -0
- data/lib/tessella/png.rb +456 -0
- data/lib/tessella/raster.rb +92 -0
- data/lib/tessella/rmagick_reader.rb +52 -0
- data/lib/tessella/rule.rb +59 -0
- data/lib/tessella/seq.rb +522 -0
- data/lib/tessella/sparkline.rb +57 -0
- data/lib/tessella/stats.rb +199 -0
- data/lib/tessella/table.rb +547 -0
- data/lib/tessella/table_cells.rb +225 -0
- data/lib/tessella/target.rb +105 -0
- data/lib/tessella/text.rb +76 -0
- data/lib/tessella/text_forms.rb +150 -0
- data/lib/tessella/tree.rb +221 -0
- data/lib/tessella/version.rb +5 -0
- data/lib/tessella/wave.rb +353 -0
- data/lib/tessella/width.rb +122 -0
- data/lib/tessella/width_class.rb +77 -0
- data/lib/tessella/width_invisible.rb +66 -0
- data/lib/tessella/width_invisible_high.rb +60 -0
- data/lib/tessella.rb +94 -0
- metadata +226 -0
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tessella
|
|
4
|
+
# `Tessella.table` (ported from the ascii-art skill's
|
|
5
|
+
# scripts/table.rb): rows of Strings framed as a monospace table,
|
|
6
|
+
# in any of the script's glyph sets, or as a native GFM pipe
|
|
7
|
+
# table under style: :markdown. A column is right-aligned when
|
|
8
|
+
# every non-blank body cell reads as a number (Integer() or
|
|
9
|
+
# Float() with exception: false); align: overrides that per
|
|
10
|
+
# column. Width is columns (Width), not bytes, so CJK cells still
|
|
11
|
+
# line up.
|
|
12
|
+
module Table
|
|
13
|
+
# Table-only glyphs: the tees and crosses a framed table needs
|
|
14
|
+
# beyond the corners and runs Borders already has (DESIGN.md
|
|
15
|
+
# names Borders as shared); minimal's whole set is its own,
|
|
16
|
+
# since every one of its slots differs from a box style's.
|
|
17
|
+
module Glyphs
|
|
18
|
+
TEES = {
|
|
19
|
+
ascii: { top_t: "+", bot_t: "+", sep_l: "+", sep_t: "+",
|
|
20
|
+
sep_r: "+" },
|
|
21
|
+
light: { top_t: "┬", bot_t: "┴", sep_l: "├", sep_t: "┼",
|
|
22
|
+
sep_r: "┤" },
|
|
23
|
+
heavy: { top_t: "┳", bot_t: "┻", sep_l: "┣", sep_t: "╋",
|
|
24
|
+
sep_r: "┫" },
|
|
25
|
+
double: { top_t: "╦", bot_t: "╩", sep_l: "╠", sep_t: "╬",
|
|
26
|
+
sep_r: "╣" },
|
|
27
|
+
rounded: { top_t: "┬", bot_t: "┴", sep_l: "├", sep_t: "┼",
|
|
28
|
+
sep_r: "┤" },
|
|
29
|
+
dashed: { top_t: "┬", bot_t: "┴", sep_l: "├", sep_t: "┼",
|
|
30
|
+
sep_r: "┤" },
|
|
31
|
+
dashed_fine: { top_t: "┬", bot_t: "┴", sep_l: "├",
|
|
32
|
+
sep_t: "┼", sep_r: "┤" },
|
|
33
|
+
heavy_dashed: { top_t: "┳", bot_t: "┻", sep_l: "┣",
|
|
34
|
+
sep_t: "╋", sep_r: "┫" },
|
|
35
|
+
heavy_dashed_fine: { top_t: "┳", bot_t: "┻", sep_l: "┣",
|
|
36
|
+
sep_t: "╋", sep_r: "┫" },
|
|
37
|
+
double_h: { top_t: "╤", bot_t: "╧", sep_l: "╞", sep_t: "╪",
|
|
38
|
+
sep_r: "╡" },
|
|
39
|
+
double_v: { top_t: "╥", bot_t: "╨", sep_l: "╟", sep_t: "╫",
|
|
40
|
+
sep_r: "╢" }
|
|
41
|
+
}.freeze
|
|
42
|
+
|
|
43
|
+
MINIMAL = { tl: " ", tr: " ", bl: " ", br: " ", h: " ",
|
|
44
|
+
top_t: "╷", bot_t: "╵", sep_l: "╶", sep_t: "┼",
|
|
45
|
+
sep_r: "╴", sep_h: "─", cl: " ", cs: "│", cr: " " }
|
|
46
|
+
.freeze
|
|
47
|
+
|
|
48
|
+
def self.fetch(style)
|
|
49
|
+
return MINIMAL if style == :minimal
|
|
50
|
+
return custom(style) if Borders.custom?(style)
|
|
51
|
+
|
|
52
|
+
b = Borders.fetch(style)
|
|
53
|
+
TEES.fetch(style).merge(tl: b.tl, tr: b.tr, bl: b.bl,
|
|
54
|
+
br: b.br, h: b.h, sep_h: b.h,
|
|
55
|
+
cl: b.v, cs: b.v, cr: b.v)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Numeric detection (Ruby's own Integer()/Float() reading) and
|
|
60
|
+
# the alignment it drives, overridden column by column by
|
|
61
|
+
# align:.
|
|
62
|
+
module Detect
|
|
63
|
+
def self.numeric?(value)
|
|
64
|
+
s = value.strip
|
|
65
|
+
!Integer(s, exception: false).nil? ||
|
|
66
|
+
!Float(s, exception: false).nil?
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def self.column_numeric?(body, i)
|
|
70
|
+
cells = body.map { |row| row[i] }.reject { |c| c.strip.empty? }
|
|
71
|
+
return false if cells.empty?
|
|
72
|
+
|
|
73
|
+
cells.all? { |c| numeric?(c) }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def self.auto_align(body, i)
|
|
77
|
+
column_numeric?(body, i) ? :right : :left
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def self.resolve_aligns(body, align, ncols)
|
|
81
|
+
return parse_align(align) if align
|
|
82
|
+
|
|
83
|
+
Array.new(ncols) { |i| auto_align(body, i) }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# "lrc" letters, or an Array of :left / :right / :center.
|
|
87
|
+
def self.parse_align(align)
|
|
88
|
+
return align.map { align_symbol(it) } if align.is_a?(Array)
|
|
89
|
+
|
|
90
|
+
check_chars!(align)
|
|
91
|
+
align.chars.map { |c| Table::ALIGN_LETTERS.fetch(c.downcase) }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def self.align_symbol(name)
|
|
95
|
+
sym = name.to_s.to_sym
|
|
96
|
+
return sym if Table::ALIGN_LETTERS.value?(sym)
|
|
97
|
+
|
|
98
|
+
raise ArgumentError, "unknown align: #{name.inspect}"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def self.check_chars!(align)
|
|
102
|
+
return if /\A[lrc]+\z/i.match?(align)
|
|
103
|
+
|
|
104
|
+
raise ArgumentError,
|
|
105
|
+
"align must be letters l, r or c: #{align.inspect}"
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The framed (box) styles: ascii, light, heavy, double,
|
|
110
|
+
# rounded, minimal -- a title row, a header separator, body
|
|
111
|
+
# rows and the outer frame, from one glyph Hash (Glyphs).
|
|
112
|
+
module Render
|
|
113
|
+
def self.pad_cell(text, width, align)
|
|
114
|
+
case align
|
|
115
|
+
when :right then Width.rjust("#{text} ", width)
|
|
116
|
+
when :center then Width.center(text, width)
|
|
117
|
+
else Width.ljust(" #{text}", width)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# One row of cells (each an Array of lines) as padded output
|
|
122
|
+
# lines, each an Array of padded cells.
|
|
123
|
+
def self.build_row(row, widths, aligns)
|
|
124
|
+
Cells.rows_of(row).map do |line|
|
|
125
|
+
line.zip(widths, aligns).map { |c, w, a| pad_cell(c, w, a) }
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# The columns under a spanning row: the cells and the
|
|
130
|
+
# separators between them (two columns each under :cjk).
|
|
131
|
+
def self.span_width(widths, sep = "|")
|
|
132
|
+
widths.sum + ((widths.size - 1) * Width.of(sep))
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def self.span_row(left, fill, right, widths)
|
|
136
|
+
left + Width.run(fill, span_width(widths, fill)) + right
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def self.border_row(left, fill, sep, right, widths)
|
|
140
|
+
left + widths.map { Width.run(fill, it) }.join(sep) + right
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def self.content_row(left, sep, right, cells)
|
|
144
|
+
left + cells.join(sep) + right
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def self.title_lines(g, title, widths)
|
|
148
|
+
[span_row(g[:tl], g[:h], g[:tr], widths),
|
|
149
|
+
content_row(g[:cl], g[:cl], g[:cr],
|
|
150
|
+
[Width.center(title,
|
|
151
|
+
span_width(widths, g[:cs]))]),
|
|
152
|
+
border_row(g[:sep_l], g[:sep_h], g[:top_t], g[:sep_r],
|
|
153
|
+
widths)]
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def self.header_lines(g, header_lines, widths)
|
|
157
|
+
return [] unless header_lines
|
|
158
|
+
|
|
159
|
+
header_lines.map { content_row(g[:cl], g[:cs], g[:cr], it) } +
|
|
160
|
+
[border_row(g[:sep_l], g[:sep_h], g[:sep_t], g[:sep_r],
|
|
161
|
+
widths)]
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def self.top_border(g, title, widths)
|
|
165
|
+
return title_lines(g, title, widths) if title
|
|
166
|
+
|
|
167
|
+
[border_row(g[:tl], g[:h], g[:top_t], g[:tr], widths)]
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def self.bottom_border(g, widths)
|
|
171
|
+
border_row(g[:bl], g[:h], g[:bot_t], g[:br], widths)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def self.body_lines(g, body_rows)
|
|
175
|
+
body_rows.flatten(1).map do |line|
|
|
176
|
+
content_row(g[:cl], g[:cs], g[:cr], line)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def self.render_box(g, header_cells, body_cells, widths, title)
|
|
181
|
+
top_border(g, title, widths) +
|
|
182
|
+
header_lines(g, header_cells, widths) +
|
|
183
|
+
body_lines(g, body_cells) + [bottom_border(g, widths)]
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# The markdown style: a native GFM pipe table, --- marking a
|
|
188
|
+
# right-aligned column and :---: a centred one; a title is a
|
|
189
|
+
# plain line above it, blank-line separated.
|
|
190
|
+
module Markdown
|
|
191
|
+
FLOORS = { left: 1, right: 2, center: 3 }.freeze
|
|
192
|
+
|
|
193
|
+
def self.md_min_width(width, align)
|
|
194
|
+
[width, FLOORS.fetch(align)].max
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def self.md_delim_cell(width, align)
|
|
198
|
+
case align
|
|
199
|
+
when :right then "#{"-" * (width - 1)}:"
|
|
200
|
+
when :center then ":#{"-" * (width - 2)}:"
|
|
201
|
+
else "-" * width
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def self.md_row(cells) = "|#{cells.join("|")}|"
|
|
206
|
+
|
|
207
|
+
def self.md_padded_row(cells, widths, aligns)
|
|
208
|
+
cells.each_index.map do |i|
|
|
209
|
+
Render.pad_cell(Cells.md_cell(cells[i]), widths[i],
|
|
210
|
+
aligns[i])
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def self.md_widths(widths, aligns)
|
|
215
|
+
widths.each_index.map { |i| md_min_width(widths[i], aligns[i]) }
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def self.md_delim_row(mw, aligns)
|
|
219
|
+
cells = mw.each_index.map do |i|
|
|
220
|
+
md_delim_cell(mw[i], aligns[i])
|
|
221
|
+
end
|
|
222
|
+
md_row(cells)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def self.render(header, body, layout)
|
|
226
|
+
aligns = layout.aligns
|
|
227
|
+
mw = md_widths(layout.widths, aligns)
|
|
228
|
+
head = header || Array.new(layout.widths.size, "")
|
|
229
|
+
rows = [md_row(md_padded_row(head, mw, aligns)),
|
|
230
|
+
md_delim_row(mw, aligns)]
|
|
231
|
+
rows += body.map { |r| md_row(md_padded_row(r, mw, aligns)) }
|
|
232
|
+
layout.title ? [layout.title, "", *rows] : rows
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
ALIGN_LETTERS = { "l" => :left, "r" => :right, "c" => :center }
|
|
237
|
+
.freeze
|
|
238
|
+
MIN_WIDTH = 4
|
|
239
|
+
STYLE_NAMES = (Glyphs::TEES.keys + %i[minimal markdown]).freeze
|
|
240
|
+
|
|
241
|
+
# The three things a render needs beyond the header and body
|
|
242
|
+
# cells themselves, bundled so render and Markdown.render stay
|
|
243
|
+
# under the house parameter-count budget.
|
|
244
|
+
Layout = Data.define(:widths, :aligns, :title)
|
|
245
|
+
|
|
246
|
+
def self.sanitize(text) = text.tr("\t", " ")
|
|
247
|
+
|
|
248
|
+
# rows: Array of Arrays of String; the first is the header
|
|
249
|
+
# unless header: false. Raises ArgumentError naming a ragged
|
|
250
|
+
# row, an empty table, an align: of the wrong length, an
|
|
251
|
+
# unknown style, a table that cannot fit width:, or (markdown)
|
|
252
|
+
# a multi-line cell or a width:.
|
|
253
|
+
# rubocop:disable-next Metrics/ParameterLists
|
|
254
|
+
def self.build(rows, header:, style:, ascii:, align:, title:, **fit)
|
|
255
|
+
head, body = split(rows, header)
|
|
256
|
+
check_ascii!(rows, title) if ascii
|
|
257
|
+
ncols = (head || body.first).size
|
|
258
|
+
check_align_length!(align, ncols)
|
|
259
|
+
aligns = Detect.resolve_aligns(body, align, ncols)
|
|
260
|
+
style = resolve_style!(style, ascii)
|
|
261
|
+
return markdown(head, body, aligns, title, fit) if
|
|
262
|
+
style == :markdown
|
|
263
|
+
|
|
264
|
+
framed(style, Cells.grid([head, *body].compact), head, aligns,
|
|
265
|
+
title, fit)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# rubocop:disable-next Metrics/ParameterLists
|
|
269
|
+
def self.framed(style, grid, head, aligns, title, fit)
|
|
270
|
+
g = Edges.glyphs(style)
|
|
271
|
+
widths = Cells.column_widths(grid)
|
|
272
|
+
room = Edges.fit_width(fit, g, widths)
|
|
273
|
+
grid, widths = Fit.apply(grid, widths, room,
|
|
274
|
+
fit.fetch(:min_width, MIN_WIDTH))
|
|
275
|
+
head_row, *body = head ? grid : [nil, *grid]
|
|
276
|
+
layout = Layout.new(widths, aligns, Cells.title_text(title))
|
|
277
|
+
Block.new(render(g, Cells.header(head_row, fit[:color]), body,
|
|
278
|
+
layout))
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def self.markdown(head, body, aligns, title, fit)
|
|
282
|
+
Cells.check_markdown!(head, body, fit)
|
|
283
|
+
widths = compute_widths(head, body, (head || body.first).size)
|
|
284
|
+
layout = Layout.new(widths, aligns, Cells.title_text(title))
|
|
285
|
+
Block.new(Markdown.render(head, body, layout))
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def self.split(rows, header)
|
|
289
|
+
check_rows!(rows)
|
|
290
|
+
header ? [rows[0], rows[1..]] : [nil, rows]
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def self.check_rows!(rows)
|
|
294
|
+
check_ragged!(rows)
|
|
295
|
+
raise ArgumentError, "no rows to tabulate" if
|
|
296
|
+
rows.empty? || rows.first.empty?
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def self.check_ragged!(rows)
|
|
300
|
+
return if rows.empty?
|
|
301
|
+
|
|
302
|
+
ncols = rows.first.size
|
|
303
|
+
rows.each_with_index do |row, i|
|
|
304
|
+
next if row.size == ncols
|
|
305
|
+
|
|
306
|
+
raise ArgumentError, "line #{i + 1}: expected #{ncols} " \
|
|
307
|
+
"fields, got #{row.size}"
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def self.check_align_length!(align, ncols)
|
|
312
|
+
return unless align && align.length != ncols
|
|
313
|
+
|
|
314
|
+
raise ArgumentError,
|
|
315
|
+
"align has #{align.length} letters, #{ncols} columns"
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def self.compute_widths(head, body, ncols)
|
|
319
|
+
Array.new(ncols) { |i| column_width(head, body, i) }
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def self.column_width(head, body, i)
|
|
323
|
+
cells = body.map { |row| Cells.md_cell(row[i]) }
|
|
324
|
+
cells << Cells.md_cell(head[i]) if head
|
|
325
|
+
cells.map { |c| Width.of(c) }.max + 2
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# A custom set stays itself, checked as ASCII under ascii:
|
|
329
|
+
# true; a named style gives way to :ascii there, but markdown,
|
|
330
|
+
# which is ASCII already.
|
|
331
|
+
def self.resolve_style!(style, ascii)
|
|
332
|
+
if Borders.custom?(style)
|
|
333
|
+
Glyphs.custom(style)
|
|
334
|
+
Borders.custom(style, ascii: ascii)
|
|
335
|
+
return style
|
|
336
|
+
end
|
|
337
|
+
sym = style.nil? ? :light : normalize_style!(style)
|
|
338
|
+
ascii && sym != :markdown ? :ascii : sym
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def self.normalize_style!(style)
|
|
342
|
+
sym = Borders.name(style)
|
|
343
|
+
return sym if STYLE_NAMES.include?(sym)
|
|
344
|
+
|
|
345
|
+
raise ArgumentError, "unknown style: #{style.inspect}"
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# Each column a whole number of border glyphs wide, so under
|
|
349
|
+
# target :cjk, where a Unicode one takes two columns, the
|
|
350
|
+
# borders meet the cells.
|
|
351
|
+
def self.render(g, head, body, layout)
|
|
352
|
+
aligns = layout.aligns
|
|
353
|
+
unit = Edges.unit(g)
|
|
354
|
+
widths = Edges.titled(layout.widths, layout.title, g)
|
|
355
|
+
.map { Width.round_up(it, unit) }
|
|
356
|
+
padded_head = head && Render.build_row(head, widths, aligns)
|
|
357
|
+
padded_body = body.map { |r| Render.build_row(r, widths, aligns) }
|
|
358
|
+
Render.render_box(g, padded_head, padded_body, widths,
|
|
359
|
+
layout.title)
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# A small RFC 4180 reader: no runtime dependency, so a
|
|
363
|
+
# hand-written one rather than Ruby's "csv", a bundled gem
|
|
364
|
+
# under Ruby 3.4 and not the standard library (CLAUDE.md,
|
|
365
|
+
# DESIGN.md's "no runtime dependency"). Quoted fields, ""
|
|
366
|
+
# escapes, a separator or newline inside quotes, and \n, \r\n
|
|
367
|
+
# or \r row endings; checked against CSV.parse on the same
|
|
368
|
+
# text in the port's parity script, not here.
|
|
369
|
+
class CSVReader
|
|
370
|
+
def self.parse(text, sep)
|
|
371
|
+
new(text, sep).run
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def initialize(text, sep)
|
|
375
|
+
@text = text
|
|
376
|
+
@sep = sep
|
|
377
|
+
@len = text.length
|
|
378
|
+
@pos = 0
|
|
379
|
+
@rows = []
|
|
380
|
+
@row = []
|
|
381
|
+
@field = +""
|
|
382
|
+
@state = :empty # :empty, :unquoted, :quoted, :closed
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def run
|
|
386
|
+
step until @pos >= @len
|
|
387
|
+
bad!("unterminated quoted field") if @state == :quoted
|
|
388
|
+
terminate_record! unless blank_record?
|
|
389
|
+
@rows
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
private
|
|
393
|
+
|
|
394
|
+
def bad!(msg)
|
|
395
|
+
raise ArgumentError, "#{msg} in line #{@rows.size + 1}"
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def step
|
|
399
|
+
c = @text[@pos]
|
|
400
|
+
case @state
|
|
401
|
+
when :quoted then quoted_char(c)
|
|
402
|
+
when :closed then closed_char(c)
|
|
403
|
+
else open_char(c)
|
|
404
|
+
end
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
def open_char(c)
|
|
408
|
+
return start_quote if c == '"' && @state == :empty
|
|
409
|
+
return bad!("illegal quoting") if c == '"'
|
|
410
|
+
return advance_field if c == @sep
|
|
411
|
+
return end_of_line if newline?(c)
|
|
412
|
+
|
|
413
|
+
@field << c
|
|
414
|
+
@state = :unquoted
|
|
415
|
+
@pos += 1
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
def closed_char(c)
|
|
419
|
+
return advance_field if c == @sep
|
|
420
|
+
return end_of_line if newline?(c)
|
|
421
|
+
|
|
422
|
+
bad!("a value found after a closed quoted field")
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def quoted_char(c)
|
|
426
|
+
return peek_quote if c == '"'
|
|
427
|
+
|
|
428
|
+
@field << c
|
|
429
|
+
@pos += 1
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
def peek_quote
|
|
433
|
+
if @text[@pos + 1] == '"'
|
|
434
|
+
@field << '"'
|
|
435
|
+
@pos += 2
|
|
436
|
+
else
|
|
437
|
+
@state = :closed
|
|
438
|
+
@pos += 1
|
|
439
|
+
end
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def start_quote
|
|
443
|
+
@state = :quoted
|
|
444
|
+
@pos += 1
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
def newline?(c) = %W[\n \r].include?(c)
|
|
448
|
+
|
|
449
|
+
def crlf? = @text[@pos] == "\r" && @text[@pos + 1] == "\n"
|
|
450
|
+
|
|
451
|
+
def advance_field
|
|
452
|
+
push_field
|
|
453
|
+
@pos += 1
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
def end_of_line
|
|
457
|
+
terminate_record!
|
|
458
|
+
@pos += crlf? ? 2 : 1
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
def terminate_record!
|
|
462
|
+
push_field unless blank_record?
|
|
463
|
+
push_row
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
def blank_record?
|
|
467
|
+
@row.empty? && @field.empty? && @state == :empty
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
def push_field
|
|
471
|
+
@row << @field
|
|
472
|
+
@field = +""
|
|
473
|
+
@state = :empty
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
def push_row
|
|
477
|
+
@rows << @row
|
|
478
|
+
@row = []
|
|
479
|
+
end
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
# Parses CSV (or, tsv: true, tab-separated) text into rows of
|
|
483
|
+
# Strings with CSVReader; raises ArgumentError on malformed
|
|
484
|
+
# input (an unterminated quote, a stray quote outside one).
|
|
485
|
+
def self.parse(text, tsv: false)
|
|
486
|
+
CSVReader.parse(text, tsv ? "\t" : ",")
|
|
487
|
+
end
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
# A custom border set for a table (Borders): eleven glyphs, the six
|
|
491
|
+
# of a frame and the five tees; and, under ascii: true, the check
|
|
492
|
+
# that every cell and the title are ASCII.
|
|
493
|
+
module Table
|
|
494
|
+
# Cells as text: a String as it is, a number as its digits (so a
|
|
495
|
+
# numeric column still right-aligns); anything else refused,
|
|
496
|
+
# naming its row and column.
|
|
497
|
+
def self.cell_text(rows)
|
|
498
|
+
Array(rows).each_with_index.map do |row, r|
|
|
499
|
+
Array(row).each_with_index.map do |cell, c|
|
|
500
|
+
next cell if cell.is_a?(String)
|
|
501
|
+
next cell.to_s if cell.is_a?(Numeric)
|
|
502
|
+
|
|
503
|
+
raise ArgumentError, "row #{r + 1}, column #{c + 1}: a " \
|
|
504
|
+
"cell must be a String or a number: " \
|
|
505
|
+
"#{cell.inspect}"
|
|
506
|
+
end
|
|
507
|
+
end
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
def self.check_ascii!(rows, title)
|
|
511
|
+
Check.ascii!(title, "title")
|
|
512
|
+
rows.each_with_index do |row, r|
|
|
513
|
+
row.each_with_index do |cell, c|
|
|
514
|
+
Check.ascii!(cell, "row #{r + 1}, column #{c + 1}")
|
|
515
|
+
end
|
|
516
|
+
end
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
module Glyphs
|
|
520
|
+
# An 11-glyph set: the six of a frame, and the five tees.
|
|
521
|
+
def self.custom(style)
|
|
522
|
+
b, tees = Borders.custom(style)
|
|
523
|
+
unless tees
|
|
524
|
+
raise ArgumentError, "a table's border set needs 11 " \
|
|
525
|
+
"glyphs, its tees after the six " \
|
|
526
|
+
"of a frame: #{style.inspect}"
|
|
527
|
+
end
|
|
528
|
+
tees.merge(tl: b.tl, tr: b.tr, bl: b.bl, br: b.br, h: b.h,
|
|
529
|
+
sep_h: b.h, cl: b.v, cs: b.v, cr: b.v)
|
|
530
|
+
end
|
|
531
|
+
end
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
# width: fits the framed table to that many columns by narrowing
|
|
535
|
+
# the widest column a column at a time (wrapping its cells),
|
|
536
|
+
# refusing only with every column at min_width or its natural
|
|
537
|
+
# width; color: true makes the header bold.
|
|
538
|
+
# rubocop:disable-next Metrics/ParameterLists
|
|
539
|
+
def self.table(rows, header: true, style: nil, ascii: Target.ascii?,
|
|
540
|
+
align: nil, title: nil, width: nil,
|
|
541
|
+
min_width: Table::MIN_WIDTH, color: false)
|
|
542
|
+
Table.build(Table.cell_text(rows),
|
|
543
|
+
header: header, style: style, ascii: ascii,
|
|
544
|
+
align: align, title: title, width: width,
|
|
545
|
+
min_width: min_width, color: color)
|
|
546
|
+
end
|
|
547
|
+
end
|