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,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
|