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,454 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tessella
4
+ # A picture as text (DESIGN.md, "Images"): tone ramps, shade
5
+ # blocks, half/quadrant/sextant/braille pixels, or Sobel edges,
6
+ # with error-diffusion or ordered dithering. Ported from the
7
+ # ascii-art skill's scripts/img2art.rb; Tessella.image is the
8
+ # entry point, at the bottom of this file. Its --pad is dropped:
9
+ # composition pads a block itself (DESIGN.md, "Blocks and
10
+ # composition"), and a blank braille cell is U+0020, never
11
+ # U+2800, so every other house rule (no trailing blank, ASCII
12
+ # from the first byte) falls out of Block on its own.
13
+ module Image
14
+ RAMPS = { ramp: " .:-=+*#%@", blocks: " ░▒▓█" }.freeze
15
+ QUAD = " ▘▝▀▖▌▞▛▗▚▐▜▄▙▟█"
16
+ BRAILLE_BITS = [[0x01, 0x08], [0x02, 0x10], [0x04, 0x20],
17
+ [0x40, 0x80]].freeze
18
+ # U+1FB00.. enumerates the 6-bit sextant subsets in binary
19
+ # order (UL=1 UR=2 ML=4 MR=8 LL=16 LR=32), skipping empty/full
20
+ # and the two half-blocks, which live in U+2580-259F.
21
+ SEXTANT = (0..63).map do |v|
22
+ case v
23
+ when 0 then " "
24
+ when 21 then "▌"
25
+ when 42 then "▐"
26
+ when 63 then "█"
27
+ else [0x1FB00 + v - 1 - (v > 21 ? 1 : 0) - (v > 42 ? 1 : 0)]
28
+ .pack("U")
29
+ end
30
+ end.freeze
31
+ STYLES = %i[ramp blocks half quadrant sextant braille octant
32
+ petscii edges].freeze
33
+ DITHERS = %i[none fs atkinson bayer].freeze
34
+ SUBCELL = { ramp: [1, 1], blocks: [1, 1], half: [1, 2],
35
+ quadrant: [2, 2], sextant: [2, 3], braille: [2, 4],
36
+ octant: [2, 4], petscii: [4, 8],
37
+ edges: [1, 1] }.freeze
38
+ DEFAULTS = { style: :ramp, width: 78, ramp: nil, gamma: 1.0,
39
+ threshold: 0.5, dither: :fs, invert: false,
40
+ edge_threshold: 0.3, ascii: false }.freeze
41
+
42
+ # A picture whose every cell is blank (edges on a flat image, a
43
+ # threshold above every tone) is refused as Blank, naming the
44
+ # options to move, rather than returned as an empty figure.
45
+ class Blank < ArgumentError; end
46
+
47
+ def self.build(source, opts)
48
+ raster = Source.raster(source)
49
+ resolved = Options.resolve(DEFAULTS.merge(ascii: Target.ascii?,
50
+ **opts))
51
+ rows = Renderer.rows(raster, resolved)
52
+ if rows.all? { it.strip.empty? }
53
+ raise Blank, blank_message(resolved)
54
+ end
55
+
56
+ Block.new(rows)
57
+ end
58
+
59
+ def self.blank_message(opts)
60
+ knob = opts[:style] == :edges ? "edge_threshold" : "threshold"
61
+ "the picture renders blank at style #{opts[:style]}: lower " \
62
+ "#{knob}, or try invert or another style"
63
+ end
64
+ end
65
+
66
+ # Validates Tessella.image's keyword options and resolves the
67
+ # tone ramp, exactly as img2art.rb's OptionParser does, plus the
68
+ # ASCII rule this library adds: ascii: true allows only
69
+ # style: :ramp with an ASCII ramp (DESIGN.md).
70
+ module Image
71
+ class Options
72
+ def self.resolve(opts)
73
+ new(opts).resolve
74
+ end
75
+
76
+ def initialize(opts)
77
+ @opts = opts
78
+ end
79
+
80
+ def resolve
81
+ check_style!
82
+ check_dither!
83
+ Check.positive_integer!(@opts[:width], "width")
84
+ check_numeric!
85
+ chars = resolve_ramp
86
+ check_ascii!(chars)
87
+ @opts.merge(chars: chars)
88
+ end
89
+
90
+ private
91
+
92
+ def check_style!
93
+ return if Image::STYLES.include?(@opts[:style])
94
+
95
+ raise ArgumentError, "unknown style: #{@opts[:style].inspect}"
96
+ end
97
+
98
+ def check_dither!
99
+ return if Image::DITHERS.include?(@opts[:dither])
100
+
101
+ raise ArgumentError,
102
+ "unknown dither: #{@opts[:dither].inspect}"
103
+ end
104
+
105
+ def check_numeric!
106
+ Check.finite!([@opts[:gamma]], "gamma")
107
+ Check.finite!([@opts[:threshold]], "threshold")
108
+ Check.finite!([@opts[:edge_threshold]], "edge_threshold")
109
+ Check.positive!(@opts[:gamma], "gamma")
110
+ end
111
+
112
+ # A ramp named in the glyph registry (ramp, blocks, long,
113
+ # hatch), else the glyphs given; a name wins.
114
+ def resolve_ramp
115
+ ramp = @opts[:ramp]
116
+ named = ramp && Tessella::Glyphs::RAMPS[Borders.name(ramp)]
117
+ return named if named
118
+ return check_custom_ramp!(ramp) if ramp
119
+
120
+ Image::RAMPS.fetch(@opts[:style], Image::RAMPS[:ramp])
121
+ end
122
+
123
+ # Glyphs of one's own: a String. A Symbol is a ramp's name,
124
+ # and one the registry lacks is refused, not drawn with.
125
+ def check_custom_ramp!(ramp)
126
+ unless ramp.is_a?(String)
127
+ names = Tessella::Glyphs::RAMPS.keys.join(", ")
128
+ raise ArgumentError, "unknown ramp: #{ramp.inspect} " \
129
+ "(#{names}, or glyphs)"
130
+ end
131
+ raise ArgumentError, "ramp must not be empty" if ramp.empty?
132
+
133
+ ramp
134
+ end
135
+
136
+ def check_ascii!(chars)
137
+ return unless @opts[:ascii]
138
+
139
+ check_ascii_style!
140
+ return if chars.ascii_only?
141
+
142
+ raise ArgumentError,
143
+ "ascii: true needs an ASCII ramp: #{chars.inspect}"
144
+ end
145
+
146
+ def check_ascii_style!
147
+ return if @opts[:style] == :ramp
148
+
149
+ raise ArgumentError,
150
+ "ascii: true allows only style: :ramp, not " \
151
+ "#{@opts[:style].inspect}"
152
+ end
153
+ end
154
+ end
155
+
156
+ # Tessella.image's source: a Raster already decoded, an IO to
157
+ # read as image bytes (Raster.read), or a String of image bytes
158
+ # (Raster.decode).
159
+ module Image
160
+ module Source
161
+ def self.raster(source)
162
+ return source if source.is_a?(Raster)
163
+ return Raster.read(source) if source.respond_to?(:read)
164
+ return Raster.decode(source) if source.is_a?(String)
165
+
166
+ raise ArgumentError,
167
+ "source must be a Raster, IO, or String: " \
168
+ "#{source.inspect}"
169
+ end
170
+ end
171
+ end
172
+
173
+ # Floyd-Steinberg / Atkinson error diffusion, or a 4x4 Bayer
174
+ # threshold matrix, turning a sampled luma grid into on/off
175
+ # cells before a style's glyph table reads it. Ported from
176
+ # img2art.rb's Dither, byte for byte but for dither: as a Symbol.
177
+ module Image
178
+ class Dither
179
+ KERNELS = {
180
+ fs: [[1, 0, 7], [-1, 1, 3], [0, 1, 5], [1, 1, 1]]
181
+ .map { |x, y, w| [x, y, w / 16.0] },
182
+ atkinson: [[1, 0], [2, 0], [-1, 1], [0, 1], [1, 1], [0, 2]]
183
+ .map { |x, y| [x, y, 1 / 8.0] }
184
+ }.freeze
185
+
186
+ BAYER4 = [[0, 8, 2, 10], [12, 4, 14, 6], [3, 11, 1, 9],
187
+ [15, 7, 13, 5]].freeze
188
+
189
+ def self.apply(grid, pw, ph, thr, mode)
190
+ new(grid, pw, ph, thr).run(mode)
191
+ end
192
+
193
+ def initialize(grid, pw, ph, thr)
194
+ @grid = grid
195
+ @pw = pw
196
+ @ph = ph
197
+ @thr = thr
198
+ end
199
+
200
+ def run(mode)
201
+ return bayer if mode == :bayer
202
+
203
+ diffuse(KERNELS[mode])
204
+ end
205
+
206
+ private
207
+
208
+ def bayer
209
+ @grid.each_with_index.map do |v, i|
210
+ cell = BAYER4[(i / @pw) % 4][i % @pw % 4]
211
+ v > @thr + ((cell + 0.5) / 16.0) - 0.5
212
+ end
213
+ end
214
+
215
+ def diffuse(kernel)
216
+ return @grid.map { |v| v > @thr } unless kernel
217
+
218
+ out = Array.new(@grid.size)
219
+ (0...@ph).each { |y| row(out, y, kernel) }
220
+ out
221
+ end
222
+
223
+ def row(out, y, kernel)
224
+ (0...@pw).each do |x|
225
+ i = (y * @pw) + x
226
+ on = @grid[i] > @thr
227
+ out[i] = on
228
+ spread(x, y, @grid[i] - (on ? 1.0 : 0.0), kernel)
229
+ end
230
+ end
231
+
232
+ def spread(x, y, err, kernel)
233
+ kernel.each do |dx, dy, w|
234
+ nx = x + dx
235
+ ny = y + dy
236
+ next if nx.negative? || nx >= @pw || ny >= @ph
237
+
238
+ @grid[(ny * @pw) + nx] += err * w
239
+ end
240
+ end
241
+ end
242
+ end
243
+
244
+ # Samples a Raster's luma into one float per output pixel (box
245
+ # filter, gamma and polarity applied), then dithers it into
246
+ # on/off cells for every style but ramp/blocks/edges, which read
247
+ # the float tone directly. Ported from img2art.rb's
248
+ # Renderer#sample, #box_mean, #value and #bits.
249
+ module Image
250
+ class Field
251
+ def initialize(raster, cols, rows, opts)
252
+ @raster = raster
253
+ @cols = cols
254
+ @rows = rows
255
+ @sw, @sh = SUBCELL[opts[:style]]
256
+ @pw = cols * @sw
257
+ @ph = rows * @sh
258
+ @gamma = opts[:gamma]
259
+ @invert = opts[:invert]
260
+ @thr = opts[:threshold]
261
+ @dither = opts[:dither]
262
+ end
263
+
264
+ def tone(cx, cy) = sample(cx, cy, 1, 1)
265
+
266
+ # A subcell's tone, undithered: what petscii matches.
267
+ def level(x, y) = sample(x, y, @sw, @sh)
268
+
269
+ def on?(x, y) = bits[(y * @pw) + x]
270
+
271
+ private
272
+
273
+ def bits
274
+ @bits ||= begin
275
+ grid = Array.new(@pw * @ph) do |i|
276
+ sample(i % @pw, i / @pw, @sw, @sh)
277
+ end
278
+ Dither.apply(grid, @pw, @ph, @thr, @dither)
279
+ end
280
+ end
281
+
282
+ def sample(sx, sy, sw, sh)
283
+ x0, x1 = span(sx, sw, @cols, @raster.width)
284
+ y0, y1 = span(sy, sh, @rows, @raster.height)
285
+ value(box_mean(x0, x1, y0, y1))
286
+ end
287
+
288
+ # One axis of a sample window: the source pixels an
289
+ # output subcell at s (of cells subcells wide) covers,
290
+ # at least one pixel even when size < cells * sub.
291
+ def span(s, sub, cells, size)
292
+ g = cells * sub
293
+ a = s * size / g
294
+ [a, [(s + 1) * size / g, a + 1].max]
295
+ end
296
+
297
+ def box_mean(x0, x1, y0, y1)
298
+ sum = 0.0
299
+ (y0...y1).each do |y|
300
+ (x0...x1).each do |x|
301
+ sum += @raster.luma[(y * @raster.width) + x]
302
+ end
303
+ end
304
+ sum / ((x1 - x0) * (y1 - y0))
305
+ end
306
+
307
+ def value(v)
308
+ v = 1.0 - v if @invert
309
+ v**@gamma
310
+ end
311
+ end
312
+ end
313
+
314
+ # One character per output cell, from a Field's on/off pixels or
315
+ # tone, for every style. Ported from img2art.rb's Renderer#glyph
316
+ # and its per-style methods.
317
+ module Image
318
+ class Glyphs
319
+ include OctantGlyph
320
+ include PetsciiGlyph
321
+
322
+ SOBEL_X = [[-1, -1, -1], [-1, 0, -2], [-1, 1, -1],
323
+ [1, -1, 1], [1, 0, 2], [1, 1, 1]].freeze
324
+ SOBEL_Y = [[-1, -1, -1], [0, -1, -2], [1, -1, -1],
325
+ [-1, 1, 1], [0, 1, 2], [1, 1, 1]].freeze
326
+
327
+ def initialize(field, opts, cols, rows)
328
+ @field = field
329
+ @style = opts[:style]
330
+ @chars = opts[:chars]
331
+ @cols = cols
332
+ @rows = rows
333
+ @edge_thr = opts[:edge_threshold]
334
+ end
335
+
336
+ # The style's own glyph method; the ramps' is tone.
337
+ GLYPH = { half: :half, quadrant: :quadrant, sextant: :sextant,
338
+ braille: :braille, octant: :octant,
339
+ petscii: :petscii, edges: :edge }.freeze
340
+
341
+ def glyph(cx, cy) = send(GLYPH.fetch(@style, :tone), cx, cy)
342
+
343
+ private
344
+
345
+ def tone(cx, cy)
346
+ v = @field.tone(cx, cy)
347
+ @chars[(v * (@chars.length - 1)).round]
348
+ end
349
+
350
+ def half(cx, cy)
351
+ top = @field.on?(cx, cy * 2)
352
+ bot = @field.on?(cx, (cy * 2) + 1)
353
+ [" ", "▀", "▄", "█"][(top ? 1 : 0) | (bot ? 2 : 0)]
354
+ end
355
+
356
+ def quadrant(cx, cy)
357
+ bits = 0
358
+ [[0, 0, 1], [1, 0, 2], [0, 1, 4], [1, 1, 8]]
359
+ .each do |dx, dy, bit|
360
+ bits |= bit if @field.on?((cx * 2) + dx, (cy * 2) + dy)
361
+ end
362
+ QUAD[bits]
363
+ end
364
+
365
+ def sextant(cx, cy)
366
+ bits = 0
367
+ (0...3).each do |dy|
368
+ (0...2).each do |dx|
369
+ bits |= 1 << ((dy * 2) + dx) if
370
+ @field.on?((cx * 2) + dx, (cy * 3) + dy)
371
+ end
372
+ end
373
+ SEXTANT[bits]
374
+ end
375
+
376
+ def braille(cx, cy)
377
+ bits = 0
378
+ (0...4).each do |dy|
379
+ (0...2).each do |dx|
380
+ bits |= BRAILLE_BITS[dy][dx] if
381
+ @field.on?((cx * 2) + dx, (cy * 4) + dy)
382
+ end
383
+ end
384
+ bits.zero? ? " " : [0x2800 | bits].pack("U")
385
+ end
386
+
387
+ def cell(cx, cy)
388
+ @field.tone(cx.clamp(0, @cols - 1), cy.clamp(0, @rows - 1))
389
+ end
390
+
391
+ def edge(cx, cy)
392
+ gx = SOBEL_X.sum { |dx, dy, w| w * cell(cx + dx, cy + dy) }
393
+ gy = SOBEL_Y.sum { |dx, dy, w| w * cell(cx + dx, cy + dy) }
394
+ return " " if Math.hypot(gx, gy) / 4.0 < @edge_thr
395
+
396
+ stroke(gx, gy)
397
+ end
398
+
399
+ def stroke(gx, gy)
400
+ deg = ((Math.atan2(gy, gx) * 180 / Math::PI) + 90) % 180
401
+ return "─" if deg < 22.5 || deg >= 157.5
402
+ return "╲" if deg < 67.5
403
+ return "│" if deg < 112.5
404
+
405
+ "╱"
406
+ end
407
+ end
408
+ end
409
+
410
+ # The finished grid of lines, one call per output cell through
411
+ # Glyphs. img2art.rb joined rows with "\n" and trimmed each row's
412
+ # own trailing blanks (--pad kept them); Block.new on the raw
413
+ # rows does both instead (DESIGN.md), after the ASCII rule has
414
+ # already run (Options).
415
+ module Image
416
+ class Renderer
417
+ def self.rows(raster, opts)
418
+ new(raster, opts).rows
419
+ end
420
+
421
+ def initialize(raster, opts)
422
+ @cols = opts[:width]
423
+ @rows = fit_rows(raster, @cols)
424
+ field = Field.new(raster, @cols, @rows, opts)
425
+ @glyphs = Glyphs.new(field, opts, @cols, @rows)
426
+ end
427
+
428
+ def rows = (0...@rows).map { |cy| row(cy) }
429
+
430
+ private
431
+
432
+ # Output rows follow the source aspect; a single row at
433
+ # least, however wide the source or narrow the width.
434
+ def fit_rows(raster, cols)
435
+ [(raster.height * cols / (2.0 * raster.width)).round,
436
+ 1].max
437
+ end
438
+
439
+ def row(cy)
440
+ (0...@cols).map { |cx| @glyphs.glyph(cx, cy) }.join
441
+ end
442
+ end
443
+ end
444
+
445
+ # style: :ramp, width: 78, ramp: nil, gamma: 1.0, threshold: 0.5,
446
+ # dither: :fs, invert: false, edge_threshold: 0.3, ascii: false
447
+ # (Image::DEFAULTS) are every keyword's default; **opts keeps this
448
+ # within the house parameter-count limit (as Chart#initialize
449
+ # does), not a looser contract -- an unknown keyword is ignored,
450
+ # exactly as a missing one falls back to its default.
451
+ def self.image(source, **opts)
452
+ Image.build(source, opts)
453
+ end
454
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tessella
4
+ # Octants for Tessella.image (style: :octant): 2x4 pixels per cell,
5
+ # as braille, but solid -- Unicode 16's Symbols for Legacy
6
+ # Computing Supplement, U+1CD00..1CDE5, so only the newest
7
+ # terminals (Ghostty-class) draw them yet. A pattern's bits are its
8
+ # cells row by row, left then right, top first. The block holds 230
9
+ # patterns in that bit order; the other 26 are glyphs that already
10
+ # existed, listed in EXISTING.
11
+ module Image
12
+ OCTANT_EXISTING = {
13
+ 0 => " ", 1 => "\u{1CEA8}", 2 => "\u{1CEAB}", 3 => "\u{1FB82}",
14
+ 5 => "▘", 10 => "▝", 15 => "▀", 20 => "\u{1FBE6}",
15
+ 40 => "\u{1FBE7}", 63 => "\u{1FB85}", 64 => "\u{1CEA3}",
16
+ 80 => "▖", 85 => "▌", 90 => "▞", 95 => "▛", 128 => "\u{1CEA0}",
17
+ 160 => "▗", 165 => "▚", 170 => "▐", 175 => "▜", 192 => "▂",
18
+ 240 => "▄", 245 => "▙", 250 => "▟", 252 => "▆", 255 => "█"
19
+ }.freeze
20
+
21
+ OCTANT = begin
22
+ code = 0x1CD00
23
+ (0..255).map do |bits|
24
+ OCTANT_EXISTING.fetch(bits) do
25
+ [code].pack("U").tap { code += 1 }
26
+ end
27
+ end.freeze
28
+ end
29
+
30
+ # Glyphs#octant: the cell's eight pixels, bit 0 the top left.
31
+ module OctantGlyph
32
+ private
33
+
34
+ def octant(cx, cy)
35
+ bits = 0
36
+ (0...4).each do |dy|
37
+ (0...2).each do |dx|
38
+ bits |= 1 << ((dy * 2) + dx) if
39
+ @field.on?((cx * 2) + dx, (cy * 4) + dy)
40
+ end
41
+ end
42
+ OCTANT[bits]
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tessella
4
+ # PETSCII-style pictures for Tessella.image (style: :petscii): each
5
+ # cell is sampled as 4x8 tones and drawn with the glyph whose shape
6
+ # fits them best (least squared error), from a set in the spirit of
7
+ # the Commodore character ROM -- blocks, halves and quadrants,
8
+ # quarter and three-quarter blocks on every side (the upper and
9
+ # right ones from Symbols for Legacy Computing), eighth-lines,
10
+ # diagonal triangles and lines, shades, line pieces, discs. The
11
+ # shape a glyph draws is a function of the cell's x and y (0..1, y
12
+ # down), so the set is one table. Threshold and dither do
13
+ # not apply: the match reads tones, not on/off pixels.
14
+ module Image
15
+ HLINE = ->(_x, y) { (y - 0.5).abs < 0.13 }
16
+ VLINE = ->(x, _y) { (x - 0.5).abs < 0.26 }
17
+
18
+ PETSCII_SHAPES = {
19
+ " " => ->(_x, _y) { false }, "█" => ->(_x, _y) { true },
20
+ "▀" => ->(_x, y) { y < 0.5 }, "▄" => ->(_x, y) { y >= 0.5 },
21
+ "▌" => ->(x, _y) { x < 0.5 }, "▐" => ->(x, _y) { x >= 0.5 },
22
+ "▘" => ->(x, y) { x < 0.5 && y < 0.5 },
23
+ "▝" => ->(x, y) { x >= 0.5 && y < 0.5 },
24
+ "▖" => ->(x, y) { x < 0.5 && y >= 0.5 },
25
+ "▗" => ->(x, y) { x >= 0.5 && y >= 0.5 },
26
+ "▚" => ->(x, y) { (x < 0.5) == (y < 0.5) },
27
+ "▞" => ->(x, y) { (x < 0.5) != (y < 0.5) },
28
+ "▙" => ->(x, y) { x < 0.5 || y >= 0.5 },
29
+ "▛" => ->(x, y) { x < 0.5 || y < 0.5 },
30
+ "▜" => ->(x, y) { x >= 0.5 || y < 0.5 },
31
+ "▟" => ->(x, y) { x >= 0.5 || y >= 0.5 },
32
+ "▏" => ->(x, _y) { x < 0.25 }, "▕" => ->(x, _y) { x >= 0.75 },
33
+ "▔" => ->(_x, y) { y < 0.125 }, "▁" => ->(_x, y) { y >= 0.875 },
34
+ "▊" => ->(x, _y) { x < 0.75 },
35
+ "\u{1FB8A}" => ->(x, _y) { x >= 0.25 },
36
+ "▂" => ->(_x, y) { y >= 0.75 }, "▆" => ->(_x, y) { y >= 0.25 },
37
+ "\u{1FB82}" => ->(_x, y) { y < 0.25 },
38
+ "\u{1FB85}" => ->(_x, y) { y < 0.75 },
39
+ "◢" => ->(x, y) { x + y >= 1 }, "◣" => ->(x, y) { y >= x },
40
+ "◤" => ->(x, y) { x + y < 1 }, "◥" => ->(x, y) { x > y },
41
+ "╱" => ->(x, y) { (x + y - 1).abs < 0.22 },
42
+ "╲" => ->(x, y) { (x - y).abs < 0.22 },
43
+ "╳" => ->(x, y) { (x + y - 1).abs < 0.22 || (x - y).abs < 0.22 },
44
+ "─" => HLINE, "│" => VLINE,
45
+ "┼" => ->(x, y) { HLINE.call(x, y) || VLINE.call(x, y) },
46
+ "┌" => ->(x, y) { corner(x, y, x >= 0.375, y >= 0.4375) },
47
+ "┐" => ->(x, y) { corner(x, y, x < 0.625, y >= 0.4375) },
48
+ "└" => ->(x, y) { corner(x, y, x >= 0.375, y < 0.5625) },
49
+ "┘" => ->(x, y) { corner(x, y, x < 0.625, y < 0.5625) },
50
+ "├" => lambda { |x, y|
51
+ VLINE.call(x, y) || (HLINE.call(x, y) && x >= 0.375)
52
+ },
53
+ "┤" => lambda { |x, y|
54
+ VLINE.call(x, y) || (HLINE.call(x, y) && x < 0.625)
55
+ },
56
+ "┬" => lambda { |x, y|
57
+ HLINE.call(x, y) || (VLINE.call(x, y) && y >= 0.4375)
58
+ },
59
+ "┴" => lambda { |x, y|
60
+ HLINE.call(x, y) || (VLINE.call(x, y) && y < 0.5625)
61
+ },
62
+ "●" => ->(x, y) { disc(x, y) < 0.42 },
63
+ "○" => ->(x, y) { disc(x, y).between?(0.25, 0.45) }
64
+ }.freeze
65
+
66
+ # A corner piece: the horizontal run on one side, the vertical
67
+ # on the other.
68
+ def self.corner(x, y, across, down)
69
+ (HLINE.call(x, y) && across) || (VLINE.call(x, y) && down)
70
+ end
71
+
72
+ # Distance from the cell's centre, x counted half as much: the
73
+ # cell is twice as tall as wide, so this is a round disc.
74
+ def self.disc(x, y) = Math.hypot((x - 0.5) * 0.5, y - 0.5)
75
+
76
+ PETSCII_SUB = [4, 8].freeze
77
+
78
+ # Each glyph's shape as 4x8 ink (1.0) or paper (0.0), row by
79
+ # row, plus the shades as flat tones.
80
+ PETSCII = begin
81
+ sw, sh = PETSCII_SUB
82
+ shapes = PETSCII_SHAPES.transform_values do |shape|
83
+ (0...sh).flat_map do |y|
84
+ (0...sw).map do |x|
85
+ shape.call((x + 0.5) / sw, (y + 0.5) / sh) ? 1.0 : 0.0
86
+ end
87
+ end
88
+ end
89
+ shades = { "░" => 0.25, "▒" => 0.5, "▓" => 0.75 }
90
+ .transform_values { Array.new(sw * sh, it) }
91
+ shapes.merge(shades).freeze
92
+ end
93
+
94
+ # Glyphs#petscii: the glyph of least squared error over the
95
+ # cell's 4x8 tones.
96
+ module PetsciiGlyph
97
+ private
98
+
99
+ def petscii(cx, cy)
100
+ tones = petscii_tones(cx, cy)
101
+ PETSCII.min_by do |_, ink|
102
+ ink.each_with_index.sum { |v, i| (v - tones[i])**2 }
103
+ end.first
104
+ end
105
+
106
+ def petscii_tones(cx, cy)
107
+ sw, sh = PETSCII_SUB
108
+ (0...sh).flat_map do |y|
109
+ (0...sw).map do |x|
110
+ @field.level((cx * sw) + x, (cy * sh) + y)
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end