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.
Files changed (188) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +900 -0
  4. data/doc/README.md +74 -0
  5. data/doc/bitfield.md +123 -0
  6. data/doc/charts.md +1370 -0
  7. data/doc/check.rb +75 -0
  8. data/doc/cli.md +483 -0
  9. data/doc/composition.md +599 -0
  10. data/doc/getting-started.md +121 -0
  11. data/doc/glyphs.md +134 -0
  12. data/doc/images.md +325 -0
  13. data/doc/library.md +239 -0
  14. data/doc/lint.md +270 -0
  15. data/doc/meter.md +210 -0
  16. data/doc/rule.md +51 -0
  17. data/doc/seq.md +169 -0
  18. data/doc/sparkline.md +63 -0
  19. data/doc/table.md +347 -0
  20. data/doc/targets.md +222 -0
  21. data/doc/text.md +84 -0
  22. data/doc/tree.md +108 -0
  23. data/doc/wave.md +122 -0
  24. data/examples/README.md +103 -0
  25. data/examples/ages.txt +9 -0
  26. data/examples/bad.csv +3 -0
  27. data/examples/boxes.txt +3 -0
  28. data/examples/broken.txt +4 -0
  29. data/examples/calibration.rb +12 -0
  30. data/examples/cities.csv +4 -0
  31. data/examples/cjk.rb +13 -0
  32. data/examples/comment.rb +9 -0
  33. data/examples/comment.sh +13 -0
  34. data/examples/cpu.txt +60 -0
  35. data/examples/dashboard.sh +25 -0
  36. data/examples/datasheet.rb +16 -0
  37. data/examples/disc.pgm +0 -0
  38. data/examples/disks.sh +18 -0
  39. data/examples/disks.txt +5 -0
  40. data/examples/dose.txt +7 -0
  41. data/examples/drift.txt +4 -0
  42. data/examples/ethernet.txt +3 -0
  43. data/examples/figure.c +5 -0
  44. data/examples/fit.rb +16 -0
  45. data/examples/floors.rb +21 -0
  46. data/examples/gallery.sh +20 -0
  47. data/examples/gauges.sh +31 -0
  48. data/examples/gradient.rb +16 -0
  49. data/examples/growth.txt +6 -0
  50. data/examples/hosts.csv +4 -0
  51. data/examples/icmp.txt +3 -0
  52. data/examples/ipv4.txt +12 -0
  53. data/examples/kinds.csv +4 -0
  54. data/examples/langs.csv +4 -0
  55. data/examples/lint.rb +16 -0
  56. data/examples/load.rb +14 -0
  57. data/examples/login.txt +10 -0
  58. data/examples/neg.csv +4 -0
  59. data/examples/normal.txt +200 -0
  60. data/examples/note.txt +2 -0
  61. data/examples/notes-fixed.md +17 -0
  62. data/examples/notes.md +17 -0
  63. data/examples/ohlc.txt +7 -0
  64. data/examples/os.csv +10 -0
  65. data/examples/outline.txt +8 -0
  66. data/examples/plot.txt +5 -0
  67. data/examples/refusals.rb +17 -0
  68. data/examples/report.sh +21 -0
  69. data/examples/rfc.sh +22 -0
  70. data/examples/samples.txt +7 -0
  71. data/examples/shapes.pgm +0 -0
  72. data/examples/sizes.tsv +3 -0
  73. data/examples/spi.txt +6 -0
  74. data/examples/status.sh +23 -0
  75. data/examples/stem.txt +12 -0
  76. data/examples/table-drift.txt +4 -0
  77. data/examples/temps.txt +31 -0
  78. data/examples/trig.sh +13 -0
  79. data/examples/trig.txt +102 -0
  80. data/examples/week.txt +8 -0
  81. data/examples/wide.txt +2 -0
  82. data/exe/tessella +6 -0
  83. data/lib/tessella/axis.rb +71 -0
  84. data/lib/tessella/bitfield.rb +194 -0
  85. data/lib/tessella/block.rb +56 -0
  86. data/lib/tessella/borders.rb +85 -0
  87. data/lib/tessella/canvas/area.rb +155 -0
  88. data/lib/tessella/canvas/ascii.rb +120 -0
  89. data/lib/tessella/canvas/block.rb +40 -0
  90. data/lib/tessella/canvas/braille.rb +30 -0
  91. data/lib/tessella/canvas/clip.rb +49 -0
  92. data/lib/tessella/canvas/density.rb +51 -0
  93. data/lib/tessella/canvas/dot.rb +27 -0
  94. data/lib/tessella/canvas/eighths.rb +43 -0
  95. data/lib/tessella/canvas/half.rb +51 -0
  96. data/lib/tessella/canvas/sextant.rb +36 -0
  97. data/lib/tessella/canvas.rb +168 -0
  98. data/lib/tessella/cell.rb +10 -0
  99. data/lib/tessella/chart.rb +211 -0
  100. data/lib/tessella/check.rb +52 -0
  101. data/lib/tessella/cli/bitfield.rb +121 -0
  102. data/lib/tessella/cli/chart.rb +615 -0
  103. data/lib/tessella/cli/chart_candle.rb +36 -0
  104. data/lib/tessella/cli/chart_heatmap.rb +21 -0
  105. data/lib/tessella/cli/chart_help.rb +85 -0
  106. data/lib/tessella/cli/chart_pie.rb +38 -0
  107. data/lib/tessella/cli/compose.rb +156 -0
  108. data/lib/tessella/cli/compose_options.rb +259 -0
  109. data/lib/tessella/cli/glyphs.rb +97 -0
  110. data/lib/tessella/cli/image.rb +250 -0
  111. data/lib/tessella/cli/input.rb +44 -0
  112. data/lib/tessella/cli/iso_time.rb +28 -0
  113. data/lib/tessella/cli/lint.rb +102 -0
  114. data/lib/tessella/cli/lint_options.rb +99 -0
  115. data/lib/tessella/cli/main.rb +121 -0
  116. data/lib/tessella/cli/meter.rb +80 -0
  117. data/lib/tessella/cli/rule.rb +57 -0
  118. data/lib/tessella/cli/seq.rb +117 -0
  119. data/lib/tessella/cli/sparkline.rb +72 -0
  120. data/lib/tessella/cli/table.rb +167 -0
  121. data/lib/tessella/cli/text.rb +78 -0
  122. data/lib/tessella/cli/tree.rb +100 -0
  123. data/lib/tessella/cli/wave.rb +148 -0
  124. data/lib/tessella/cli.rb +36 -0
  125. data/lib/tessella/color.rb +106 -0
  126. data/lib/tessella/compose.rb +243 -0
  127. data/lib/tessella/compose_fill.rb +176 -0
  128. data/lib/tessella/compose_grid.rb +36 -0
  129. data/lib/tessella/compose_shadow.rb +41 -0
  130. data/lib/tessella/error.rb +13 -0
  131. data/lib/tessella/frame.rb +170 -0
  132. data/lib/tessella/glyphs.rb +106 -0
  133. data/lib/tessella/image.rb +454 -0
  134. data/lib/tessella/image_octant.rb +46 -0
  135. data/lib/tessella/image_petscii.rb +116 -0
  136. data/lib/tessella/kinds/bar.rb +344 -0
  137. data/lib/tessella/kinds/box.rb +213 -0
  138. data/lib/tessella/kinds/box_upright.rb +110 -0
  139. data/lib/tessella/kinds/candlestick.rb +164 -0
  140. data/lib/tessella/kinds/columns.rb +259 -0
  141. data/lib/tessella/kinds/date_axis.rb +102 -0
  142. data/lib/tessella/kinds/error_bars.rb +71 -0
  143. data/lib/tessella/kinds/heatmap.rb +130 -0
  144. data/lib/tessella/kinds/line.rb +173 -0
  145. data/lib/tessella/kinds/multibar.rb +98 -0
  146. data/lib/tessella/kinds/multibar_columns.rb +130 -0
  147. data/lib/tessella/kinds/multibar_fine.rb +45 -0
  148. data/lib/tessella/kinds/multibar_rows.rb +93 -0
  149. data/lib/tessella/kinds/pie.rb +123 -0
  150. data/lib/tessella/kinds/pie_rim.rb +78 -0
  151. data/lib/tessella/kinds/sideways.rb +62 -0
  152. data/lib/tessella/kinds/stem.rb +134 -0
  153. data/lib/tessella/kinds/threshold.rb +73 -0
  154. data/lib/tessella/legend.rb +31 -0
  155. data/lib/tessella/lint/alphabet.rb +56 -0
  156. data/lib/tessella/lint/drift.rb +82 -0
  157. data/lib/tessella/lint/fences.rb +51 -0
  158. data/lib/tessella/lint/geometry.rb +120 -0
  159. data/lib/tessella/lint/geometry_checks.rb +224 -0
  160. data/lib/tessella/lint/lines.rb +115 -0
  161. data/lib/tessella/lint/mixing.rb +61 -0
  162. data/lib/tessella/lint/runner.rb +73 -0
  163. data/lib/tessella/lint/shadows.rb +120 -0
  164. data/lib/tessella/lint/target_rows.rb +66 -0
  165. data/lib/tessella/lint.rb +158 -0
  166. data/lib/tessella/meter.rb +133 -0
  167. data/lib/tessella/meter_upright.rb +43 -0
  168. data/lib/tessella/png.rb +456 -0
  169. data/lib/tessella/raster.rb +92 -0
  170. data/lib/tessella/rmagick_reader.rb +52 -0
  171. data/lib/tessella/rule.rb +59 -0
  172. data/lib/tessella/seq.rb +522 -0
  173. data/lib/tessella/sparkline.rb +57 -0
  174. data/lib/tessella/stats.rb +199 -0
  175. data/lib/tessella/table.rb +547 -0
  176. data/lib/tessella/table_cells.rb +225 -0
  177. data/lib/tessella/target.rb +105 -0
  178. data/lib/tessella/text.rb +76 -0
  179. data/lib/tessella/text_forms.rb +150 -0
  180. data/lib/tessella/tree.rb +221 -0
  181. data/lib/tessella/version.rb +5 -0
  182. data/lib/tessella/wave.rb +353 -0
  183. data/lib/tessella/width.rb +122 -0
  184. data/lib/tessella/width_class.rb +77 -0
  185. data/lib/tessella/width_invisible.rb +66 -0
  186. data/lib/tessella/width_invisible_high.rb +60 -0
  187. data/lib/tessella.rb +94 -0
  188. 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