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
data/lib/tessella/png.rb
ADDED
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tessella
|
|
4
|
+
# A pure-Ruby PNG decoder (DESIGN.md, "Images"): the whole
|
|
5
|
+
# still-image core of the format -- chunk parsing with CRC,
|
|
6
|
+
# colour types 0/2/3/4/6, bit depths 1/2/4/8/16, all five filter
|
|
7
|
+
# types, Adam7 interlacing, tRNS -- so a PNG needs no gem and no
|
|
8
|
+
# external program. `zlib` (a bundled default gem, required by
|
|
9
|
+
# lib/tessella.rb, never this file) does the inflating; nothing
|
|
10
|
+
# else outside the standard library is used.
|
|
11
|
+
module Png
|
|
12
|
+
SIGNATURE = "\x89PNG\r\n\x1A\n".b.freeze
|
|
13
|
+
|
|
14
|
+
# Chunk parsing: length/type/data/CRC framing, with the CRC
|
|
15
|
+
# checked against Zlib.crc32 and unknown critical chunks
|
|
16
|
+
# refused. Ancillary chunks (gAMA, sRGB, iCCP, tEXt, APNG's
|
|
17
|
+
# acTL/fcTL/fdAT, ...) are skipped without being named here --
|
|
18
|
+
# their first byte alone (lower case) says they are skippable.
|
|
19
|
+
module Chunks
|
|
20
|
+
KNOWN = %w[IHDR PLTE IDAT IEND tRNS].freeze
|
|
21
|
+
|
|
22
|
+
def self.parse(bytes)
|
|
23
|
+
offset = 8
|
|
24
|
+
chunks = []
|
|
25
|
+
loop do
|
|
26
|
+
chunk, offset = read(bytes, offset)
|
|
27
|
+
chunks << chunk
|
|
28
|
+
break if chunk[:type] == "IEND"
|
|
29
|
+
end
|
|
30
|
+
chunks
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.read(bytes, offset)
|
|
34
|
+
check_available(bytes, offset, 8)
|
|
35
|
+
length = bytes.byteslice(offset, 4).unpack1("N")
|
|
36
|
+
type = bytes.byteslice(offset + 4, 4)
|
|
37
|
+
refuse_unknown_critical(type)
|
|
38
|
+
data, crc_at = body(bytes, offset, length)
|
|
39
|
+
verify_crc(bytes, type, data, crc_at)
|
|
40
|
+
[{ type: type, data: data }, crc_at + 4]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.body(bytes, offset, length)
|
|
44
|
+
crc_at = offset + 8 + length
|
|
45
|
+
check_available(bytes, crc_at, 4)
|
|
46
|
+
[bytes.byteslice(offset + 8, length), crc_at]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.check_available(bytes, offset, needed)
|
|
50
|
+
return if bytes.bytesize >= offset + needed
|
|
51
|
+
|
|
52
|
+
raise ArgumentError, "truncated PNG stream"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.refuse_unknown_critical(type)
|
|
56
|
+
return if type[0] =~ /[a-z]/ || KNOWN.include?(type)
|
|
57
|
+
|
|
58
|
+
raise ArgumentError, "unknown critical chunk: #{type}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def self.verify_crc(bytes, type, data, crc_at)
|
|
62
|
+
expected = bytes.byteslice(crc_at, 4).unpack1("N")
|
|
63
|
+
return if Zlib.crc32(type + data) == expected
|
|
64
|
+
|
|
65
|
+
raise ArgumentError, "bad CRC in #{type} chunk"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def self.data_for(chunks, type)
|
|
69
|
+
chunks.find { it[:type] == type }&.fetch(:data)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.idat(chunks)
|
|
73
|
+
parts = chunks.select { it[:type] == "IDAT" }
|
|
74
|
+
raise ArgumentError, "missing IDAT chunk" if parts.empty?
|
|
75
|
+
|
|
76
|
+
parts.map { it[:data] }.join
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def self.ihdr(chunks)
|
|
80
|
+
first = chunks.first
|
|
81
|
+
return first[:data] if first && first[:type] == "IHDR"
|
|
82
|
+
|
|
83
|
+
raise ArgumentError, "missing IHDR chunk"
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The bit depths each colour type allows (the spec's own
|
|
88
|
+
# table), keyed by colour type.
|
|
89
|
+
HEADER_DEPTHS = {
|
|
90
|
+
0 => [1, 2, 4, 8, 16], 2 => [8, 16], 3 => [1, 2, 4, 8],
|
|
91
|
+
4 => [8, 16], 6 => [8, 16]
|
|
92
|
+
}.freeze
|
|
93
|
+
|
|
94
|
+
# IHDR, as a Data value: width, height, bit_depth, colour_type,
|
|
95
|
+
# interlace -- the fields later stages need; compression and
|
|
96
|
+
# filter method are checked here and then dropped, since the
|
|
97
|
+
# spec fixes both to 0.
|
|
98
|
+
Header = Data.define(
|
|
99
|
+
:width, :height, :bit_depth, :colour_type, :interlace
|
|
100
|
+
) do
|
|
101
|
+
def self.from_ihdr(chunks)
|
|
102
|
+
data = Chunks.ihdr(chunks)
|
|
103
|
+
unless data.bytesize == 13
|
|
104
|
+
raise ArgumentError, "bad IHDR chunk length"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
w, h, depth, type, comp, filt, inter = data.unpack("NNC5")
|
|
108
|
+
header = new(width: w, height: h, bit_depth: depth,
|
|
109
|
+
colour_type: type, interlace: inter)
|
|
110
|
+
header.check!(comp, filt)
|
|
111
|
+
header
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def check!(compression, filter_method)
|
|
115
|
+
Check.positive_integer!(width, "width")
|
|
116
|
+
Check.positive_integer!(height, "height")
|
|
117
|
+
check_depth
|
|
118
|
+
check_methods(compression, filter_method)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def check_depth
|
|
122
|
+
allowed = HEADER_DEPTHS[colour_type]
|
|
123
|
+
return if allowed&.include?(bit_depth)
|
|
124
|
+
|
|
125
|
+
raise ArgumentError,
|
|
126
|
+
"invalid colour type/bit depth: " \
|
|
127
|
+
"#{colour_type}/#{bit_depth}"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def check_methods(compression, filter_method)
|
|
131
|
+
return if compression.zero? && filter_method.zero? &&
|
|
132
|
+
[0, 1].include?(interlace)
|
|
133
|
+
|
|
134
|
+
raise ArgumentError,
|
|
135
|
+
"unsupported PNG compression, filter or interlace"
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# The palette (PLTE), as [r, g, b] 0..255 triples; required
|
|
140
|
+
# only for colour type 3, read (but unused for luma) otherwise.
|
|
141
|
+
module Palette
|
|
142
|
+
def self.parse(header, chunks)
|
|
143
|
+
data = Chunks.data_for(chunks, "PLTE")
|
|
144
|
+
return nil unless header.colour_type == 3
|
|
145
|
+
raise ArgumentError, "missing PLTE chunk" unless data
|
|
146
|
+
unless (3..768).cover?(data.bytesize) &&
|
|
147
|
+
(data.bytesize % 3).zero?
|
|
148
|
+
raise ArgumentError, "bad PLTE chunk length"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
data.unpack("C*").each_slice(3).to_a
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# tRNS, in whatever shape its colour type gives it: a single
|
|
156
|
+
# grey sample value (type 0), an [r, g, b] sample triple
|
|
157
|
+
# (type 2), or one alpha byte per palette index, padded with
|
|
158
|
+
# opaque (255) past the chunk's own length (type 3).
|
|
159
|
+
module Trns
|
|
160
|
+
def self.parse(header, data, palette)
|
|
161
|
+
return nil unless data
|
|
162
|
+
|
|
163
|
+
case header.colour_type
|
|
164
|
+
when 0 then data.unpack1("n")
|
|
165
|
+
when 2 then data.unpack("n3")
|
|
166
|
+
when 3 then palette_alpha(data, palette)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def self.palette_alpha(data, palette)
|
|
171
|
+
alpha = data.unpack("C*")
|
|
172
|
+
Array.new(palette.size) { |i| alpha[i] || 255 }
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Scanline defiltering (the spec's five filter types), byte by
|
|
177
|
+
# byte, independent of colour type and bit depth: both only
|
|
178
|
+
# set the row length and the pixel stride ("bpp") the Sub,
|
|
179
|
+
# Average and Paeth filters look back by.
|
|
180
|
+
module Unfilter
|
|
181
|
+
def self.rows(header, data, offset, width, height)
|
|
182
|
+
channels = Pixels.channel_count(header.colour_type)
|
|
183
|
+
bpp = stride(header.bit_depth, channels)
|
|
184
|
+
row_size = row_bytes(width, channels, header.bit_depth)
|
|
185
|
+
prior = "\x00".b * row_size
|
|
186
|
+
rows = Array.new(height) do
|
|
187
|
+
filter, row, offset = read_row(data, offset, row_size)
|
|
188
|
+
prior = apply(filter, row, prior, bpp)
|
|
189
|
+
end
|
|
190
|
+
[rows, offset]
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def self.stride(bit_depth, channels)
|
|
194
|
+
[((bit_depth * channels) + 7) / 8, 1].max
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def self.row_bytes(width, channels, bit_depth)
|
|
198
|
+
((width * channels * bit_depth) + 7) / 8
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def self.read_row(data, offset, row_size)
|
|
202
|
+
if data.bytesize < offset + 1 + row_size
|
|
203
|
+
raise ArgumentError, "truncated PNG image data"
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
filter = data.getbyte(offset)
|
|
207
|
+
row = data.byteslice(offset + 1, row_size).dup
|
|
208
|
+
[filter, row, offset + 1 + row_size]
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def self.apply(filter, row, prior, bpp)
|
|
212
|
+
case filter
|
|
213
|
+
when 0 then row
|
|
214
|
+
when 1 then sub(row, bpp)
|
|
215
|
+
when 2 then up(row, prior)
|
|
216
|
+
when 3 then average(row, prior, bpp)
|
|
217
|
+
when 4 then paeth(row, prior, bpp)
|
|
218
|
+
else raise ArgumentError, "bad filter type: #{filter}"
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def self.sub(row, bpp)
|
|
223
|
+
row.bytesize.times do |i|
|
|
224
|
+
a = i >= bpp ? row.getbyte(i - bpp) : 0
|
|
225
|
+
row.setbyte(i, (row.getbyte(i) + a) & 0xff)
|
|
226
|
+
end
|
|
227
|
+
row
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def self.up(row, prior)
|
|
231
|
+
row.bytesize.times do |i|
|
|
232
|
+
row.setbyte(i, (row.getbyte(i) + prior.getbyte(i)) &
|
|
233
|
+
0xff)
|
|
234
|
+
end
|
|
235
|
+
row
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def self.average(row, prior, bpp)
|
|
239
|
+
row.bytesize.times do |i|
|
|
240
|
+
a = i >= bpp ? row.getbyte(i - bpp) : 0
|
|
241
|
+
v = row.getbyte(i) + ((a + prior.getbyte(i)) / 2)
|
|
242
|
+
row.setbyte(i, v & 0xff)
|
|
243
|
+
end
|
|
244
|
+
row
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def self.paeth(row, prior, bpp)
|
|
248
|
+
row.bytesize.times do |i|
|
|
249
|
+
a = i >= bpp ? row.getbyte(i - bpp) : 0
|
|
250
|
+
b = prior.getbyte(i)
|
|
251
|
+
c = i >= bpp ? prior.getbyte(i - bpp) : 0
|
|
252
|
+
row.setbyte(i, (row.getbyte(i) + predict(a, b, c)) &
|
|
253
|
+
0xff)
|
|
254
|
+
end
|
|
255
|
+
row
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def self.predict(a, b, c)
|
|
259
|
+
p = a + b - c
|
|
260
|
+
pa = (p - a).abs
|
|
261
|
+
pb = (p - b).abs
|
|
262
|
+
pc = (p - c).abs
|
|
263
|
+
return a if pa <= pb && pa <= pc
|
|
264
|
+
return b if pb <= pc
|
|
265
|
+
|
|
266
|
+
c
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Adam7 interlacing geometry: each of the 7 passes is a
|
|
271
|
+
# defiltered sub-image of its own width and height, placed
|
|
272
|
+
# into the full width x height grid at (xstart + rx * xstep,
|
|
273
|
+
# ystart + ry * ystep); a non-interlaced image is the same
|
|
274
|
+
# walk with a single, whole-image "pass".
|
|
275
|
+
module Interlace
|
|
276
|
+
PASSES = [
|
|
277
|
+
[0, 0, 8, 8], [4, 0, 8, 8], [0, 4, 4, 8], [2, 0, 4, 4],
|
|
278
|
+
[0, 2, 2, 4], [1, 0, 2, 2], [0, 1, 1, 2]
|
|
279
|
+
].freeze
|
|
280
|
+
WHOLE = [[0, 0, 1, 1]].freeze
|
|
281
|
+
|
|
282
|
+
def self.decode(header, data)
|
|
283
|
+
passes = header.interlace == 1 ? PASSES : WHOLE
|
|
284
|
+
check_length(header, data, passes)
|
|
285
|
+
grid = Array.new(header.height) { Array.new(header.width) }
|
|
286
|
+
offset = 0
|
|
287
|
+
passes.each do |geom|
|
|
288
|
+
offset = pass(header, data, offset, geom, grid)
|
|
289
|
+
end
|
|
290
|
+
grid
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# The image data must hold every pass's rows before a grid
|
|
294
|
+
# the header's size is allocated: a few bytes may claim
|
|
295
|
+
# any width and height up to 2**31 - 1.
|
|
296
|
+
def self.check_length(header, data, passes)
|
|
297
|
+
return if data.bytesize >= byte_count(header, passes)
|
|
298
|
+
|
|
299
|
+
raise ArgumentError, "truncated PNG image data"
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def self.byte_count(header, passes)
|
|
303
|
+
channels = Pixels.channel_count(header.colour_type)
|
|
304
|
+
passes.sum do |xs, ys, xstep, ystep|
|
|
305
|
+
pw = dimension(header.width, xs, xstep)
|
|
306
|
+
ph = dimension(header.height, ys, ystep)
|
|
307
|
+
next 0 if pw.zero?
|
|
308
|
+
|
|
309
|
+
ph * (1 + Unfilter.row_bytes(pw, channels,
|
|
310
|
+
header.bit_depth))
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def self.pass(header, data, offset, geom, grid)
|
|
315
|
+
xs, ys, xstep, ystep = geom
|
|
316
|
+
pw = dimension(header.width, xs, xstep)
|
|
317
|
+
ph = dimension(header.height, ys, ystep)
|
|
318
|
+
return offset if pw.zero? || ph.zero?
|
|
319
|
+
|
|
320
|
+
rows, offset = Unfilter.rows(header, data, offset, pw, ph)
|
|
321
|
+
place(header, rows, pw, geom, grid)
|
|
322
|
+
offset
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def self.dimension(total, start, step)
|
|
326
|
+
return 0 if start >= total
|
|
327
|
+
|
|
328
|
+
((total - start - 1) / step) + 1
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def self.place(header, rows, pw, geom, grid)
|
|
332
|
+
xs, ys, xstep, ystep = geom
|
|
333
|
+
rows.each_with_index do |row, ry|
|
|
334
|
+
pixels = Pixels.samples(header, row, pw)
|
|
335
|
+
pixels.each_with_index do |px, rx|
|
|
336
|
+
grid[ys + (ry * ystep)][xs + (rx * xstep)] = px
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# Samples out of a defiltered row (1/2/4/8/16-bit, big-endian),
|
|
343
|
+
# grouped into one array per pixel, and the luma each colour
|
|
344
|
+
# type turns its pixels into -- alpha, from the channel or
|
|
345
|
+
# tRNS, composited over black as Raster.luma defines.
|
|
346
|
+
module Pixels
|
|
347
|
+
CHANNELS = { 0 => 1, 2 => 3, 3 => 1, 4 => 2, 6 => 4 }.freeze
|
|
348
|
+
|
|
349
|
+
def self.channel_count(colour_type) = CHANNELS.fetch(colour_type)
|
|
350
|
+
|
|
351
|
+
def self.samples(header, row, width)
|
|
352
|
+
channels = channel_count(header.colour_type)
|
|
353
|
+
values = extract(row, header.bit_depth, width * channels)
|
|
354
|
+
values.each_slice(channels).to_a
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def self.extract(row, bit_depth, count)
|
|
358
|
+
return row.unpack("C#{count}") if bit_depth == 8
|
|
359
|
+
return row.unpack("n#{count}") if bit_depth == 16
|
|
360
|
+
|
|
361
|
+
sub_byte(row, bit_depth, count)
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def self.sub_byte(row, bit_depth, count)
|
|
365
|
+
per_byte = 8 / bit_depth
|
|
366
|
+
mask = (1 << bit_depth) - 1
|
|
367
|
+
Array.new(count) do |i|
|
|
368
|
+
byte = row.getbyte(i / per_byte)
|
|
369
|
+
shift = bit_depth * (per_byte - 1 - (i % per_byte))
|
|
370
|
+
(byte >> shift) & mask
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def self.luma_values(header, palette, trns, grid)
|
|
375
|
+
max = (2**header.bit_depth) - 1
|
|
376
|
+
grid.flat_map do |row|
|
|
377
|
+
row.map { |px| luma(header, palette, trns, px, max) }
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def self.luma(header, palette, trns, px, max)
|
|
382
|
+
case header.colour_type
|
|
383
|
+
when 0 then grey(px, trns, max)
|
|
384
|
+
when 2 then rgb(px, trns, max)
|
|
385
|
+
when 3 then indexed(px, palette, trns)
|
|
386
|
+
when 4 then grey_alpha(px, max)
|
|
387
|
+
when 6 then rgba(px, max)
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def self.grey(px, trns, max)
|
|
392
|
+
g = px[0].fdiv(max)
|
|
393
|
+
Raster.luma(g, g, g, px[0] == trns ? 0.0 : 1.0)
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def self.rgb(px, trns, max)
|
|
397
|
+
r, g, b = px
|
|
398
|
+
Raster.luma(r / max.to_f, g / max.to_f, b / max.to_f,
|
|
399
|
+
px == trns ? 0.0 : 1.0)
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def self.indexed(px, palette, trns)
|
|
403
|
+
entry = palette[px[0]]
|
|
404
|
+
unless entry
|
|
405
|
+
raise ArgumentError, "palette index #{px[0]} out of " \
|
|
406
|
+
"range (#{palette.size} entries)"
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
r, g, b = entry
|
|
410
|
+
alpha = trns ? trns[px[0]] : 255
|
|
411
|
+
Raster.luma(r / 255.0, g / 255.0, b / 255.0, alpha / 255.0)
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def self.grey_alpha(px, max)
|
|
415
|
+
g, a = px
|
|
416
|
+
gg = g / max.to_f
|
|
417
|
+
Raster.luma(gg, gg, gg, a / max.to_f)
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def self.rgba(px, max)
|
|
421
|
+
r, g, b, a = px
|
|
422
|
+
Raster.luma(r / max.to_f, g / max.to_f, b / max.to_f,
|
|
423
|
+
a / max.to_f)
|
|
424
|
+
end
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
def self.decode(bytes)
|
|
428
|
+
chunks = Chunks.parse(check_signature(bytes.b))
|
|
429
|
+
header = Header.from_ihdr(chunks)
|
|
430
|
+
palette = Palette.parse(header, chunks)
|
|
431
|
+
trns = Trns.parse(
|
|
432
|
+
header, Chunks.data_for(chunks, "tRNS"), palette
|
|
433
|
+
)
|
|
434
|
+
grid = Interlace.decode(header, inflate(Chunks.idat(chunks)))
|
|
435
|
+
luma = Pixels.luma_values(header, palette, trns, grid)
|
|
436
|
+
to_raster(header, luma)
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
def self.to_raster(header, luma)
|
|
440
|
+
Raster.new(width: header.width, height: header.height,
|
|
441
|
+
luma: luma)
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
def self.check_signature(bytes)
|
|
445
|
+
return bytes if bytes.start_with?(SIGNATURE)
|
|
446
|
+
|
|
447
|
+
raise ArgumentError, "not a PNG stream (bad signature)"
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def self.inflate(data)
|
|
451
|
+
Zlib::Inflate.inflate(data)
|
|
452
|
+
rescue Zlib::Error => e
|
|
453
|
+
raise ArgumentError, "zlib error: #{e.message}"
|
|
454
|
+
end
|
|
455
|
+
end
|
|
456
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tessella
|
|
4
|
+
# A greyscale image to draw as text (DESIGN.md, "Images"): width,
|
|
5
|
+
# height, and luma -- width * height Floats in 0..1, row by row,
|
|
6
|
+
# top row first, 1.0 the brightest. Colour becomes luma by Rec. 601
|
|
7
|
+
# (0.299 R + 0.587 G + 0.114 B); an alpha channel is composited over
|
|
8
|
+
# black, so a transparent pixel is empty under the default polarity.
|
|
9
|
+
Raster = Data.define(:width, :height, :luma) do
|
|
10
|
+
def initialize(width:, height:, luma:)
|
|
11
|
+
Check.positive_integer!(width, "width")
|
|
12
|
+
Check.positive_integer!(height, "height")
|
|
13
|
+
if luma.size < width * height
|
|
14
|
+
raise ArgumentError, "raster too short: #{luma.size} " \
|
|
15
|
+
"values for #{width}x#{height}"
|
|
16
|
+
end
|
|
17
|
+
super
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Rec. 601 luma of an RGB triple in 0..1, alpha (0..1) over black.
|
|
21
|
+
def self.luma(red, green, blue, alpha = 1.0)
|
|
22
|
+
((0.299 * red) + (0.587 * green) + (0.114 * blue)) * alpha
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# The format, by the bytes' own signature: PNM (P2 P3 P5 P6) and
|
|
26
|
+
# PNG are decoded here, in Ruby; anything else needs RMagick.
|
|
27
|
+
def self.decode(bytes)
|
|
28
|
+
bytes = bytes.b
|
|
29
|
+
return Pnm.decode(bytes) if bytes.start_with?(/P[2356]\s/n)
|
|
30
|
+
return Png.decode(bytes) if bytes.start_with?(Png::SIGNATURE)
|
|
31
|
+
|
|
32
|
+
RMagickReader.decode(bytes)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.read(io) = decode(io.binmode.read)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# PGM / PPM, plain (P2 P3) and binary (P5 P6), maxval up to 65535,
|
|
39
|
+
# "#" comments in the header -- as the ascii-art skill's img2art.rb
|
|
40
|
+
# reads them.
|
|
41
|
+
module Pnm
|
|
42
|
+
HEADER = /\A(P[2356])(?:\s|\#[^\n]*\n)+(\d+)
|
|
43
|
+
(?:\s|\#[^\n]*\n)+(\d+)(?:\s|\#[^\n]*\n)+(\d+)\s/xn
|
|
44
|
+
|
|
45
|
+
def self.decode(bytes)
|
|
46
|
+
m = HEADER.match(bytes) or
|
|
47
|
+
raise ArgumentError, "not a PGM/PPM (P2/P3/P5/P6) stream"
|
|
48
|
+
magic, width, height, maxval = m[1], *m[2..4].map(&:to_i)
|
|
49
|
+
check_maxval(maxval)
|
|
50
|
+
values = samples(magic, maxval, bytes[m.end(0)..])
|
|
51
|
+
luma = %w[P3 P6].include?(magic) ? rgb(values) : values
|
|
52
|
+
Raster.new(width: width, height: height, luma: luma)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.samples(magic, maxval, raster)
|
|
56
|
+
ints = if %w[P2 P3].include?(magic)
|
|
57
|
+
plain_samples(raster)
|
|
58
|
+
else
|
|
59
|
+
raster.unpack(maxval < 256 ? "C*" : "n*")
|
|
60
|
+
end
|
|
61
|
+
check_samples(ints, maxval)
|
|
62
|
+
ints.map { it.fdiv(maxval) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# A plain (P2/P3) raster's samples: decimal digits only, so a
|
|
66
|
+
# "-128" or an "x" is refused rather than read as a number.
|
|
67
|
+
def self.plain_samples(raster)
|
|
68
|
+
raster.split.map do |token|
|
|
69
|
+
unless token.match?(/\A\d+\z/)
|
|
70
|
+
raise ArgumentError, "PNM sample is not a number: " \
|
|
71
|
+
"#{token.inspect}"
|
|
72
|
+
end
|
|
73
|
+
token.to_i
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def self.check_maxval(maxval)
|
|
78
|
+
return if (1..65_535).cover?(maxval)
|
|
79
|
+
|
|
80
|
+
raise ArgumentError, "PNM maxval out of 1..65535: #{maxval}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def self.check_samples(ints, maxval)
|
|
84
|
+
over = ints.find { it > maxval }
|
|
85
|
+
return unless over
|
|
86
|
+
|
|
87
|
+
raise ArgumentError, "PNM sample #{over} above maxval #{maxval}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def self.rgb(values) = values.each_slice(3).map { Raster.luma(*it) }
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tessella
|
|
4
|
+
# Every image format tessella does not decode itself (JPEG, TIFF,
|
|
5
|
+
# WebP, ...), through the rmagick gem when it is installed: a soft
|
|
6
|
+
# dependency, never declared in the gemspec (DESIGN.md, "Images").
|
|
7
|
+
# This is the one file under lib/tessella that may require, and it
|
|
8
|
+
# requires only "rmagick", only when such an image arrives, so a
|
|
9
|
+
# caller that never reads one never loads it.
|
|
10
|
+
module RMagickReader
|
|
11
|
+
GEM = "rmagick"
|
|
12
|
+
|
|
13
|
+
def self.available?
|
|
14
|
+
return @available unless @available.nil?
|
|
15
|
+
|
|
16
|
+
@available = load_gem
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.load_gem
|
|
20
|
+
require GEM
|
|
21
|
+
true
|
|
22
|
+
rescue LoadError
|
|
23
|
+
false
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.decode(bytes)
|
|
27
|
+
unless available?
|
|
28
|
+
raise ArgumentError,
|
|
29
|
+
"not a PNM or PNG image, and the rmagick gem is not " \
|
|
30
|
+
"installed: install it, or convert the image first " \
|
|
31
|
+
"(magick IMAGE pgm:- | tessella image)"
|
|
32
|
+
end
|
|
33
|
+
raster(read(bytes))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.read(bytes)
|
|
37
|
+
::Magick::Image.from_blob(bytes).first
|
|
38
|
+
rescue StandardError => e
|
|
39
|
+
raise ArgumentError, "unreadable image: #{e.message}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# RGBA as Integers in 0..QuantumRange, alpha over black.
|
|
43
|
+
def self.raster(image)
|
|
44
|
+
q = ::Magick::QuantumRange.to_f
|
|
45
|
+
px = image.export_pixels(0, 0, image.columns, image.rows, "RGBA")
|
|
46
|
+
luma = px.each_slice(4).map do |r, g, b, a|
|
|
47
|
+
Raster.luma(r / q, g / q, b / q, a / q)
|
|
48
|
+
end
|
|
49
|
+
Raster.new(width: image.columns, height: image.rows, luma: luma)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tessella
|
|
4
|
+
# A divider (DESIGN.md, "Meters and rules"): a line width columns
|
|
5
|
+
# long in a rule style from Glyphs::RULES (light, heavy, double,
|
|
6
|
+
# dashed, ascii), with an optional ornament centred in it by
|
|
7
|
+
# display width and set off by a space on each side --
|
|
8
|
+
# "────────── ✦ ──────────".
|
|
9
|
+
module Rule
|
|
10
|
+
def self.build(width: 78, ornament: nil, style: :light,
|
|
11
|
+
ascii: Target.ascii?)
|
|
12
|
+
Check.positive_integer!(width, "width")
|
|
13
|
+
line = glyph!(ascii ? :ascii : style)
|
|
14
|
+
return Block.new([Width.run(line, width)]) if ornament.to_s.empty?
|
|
15
|
+
|
|
16
|
+
Block.new([ornamented(line, ornament!(ornament, ascii), width)])
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.glyph!(style)
|
|
20
|
+
Glyphs::RULES.fetch(Borders.name(style)) do
|
|
21
|
+
raise ArgumentError, "unknown rule style: #{style.inspect} " \
|
|
22
|
+
"(#{Glyphs::RULES.keys.join(", ")})"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.ornament!(ornament, ascii)
|
|
27
|
+
Compose.text!("ornament", ornament.to_s, ascii)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The ornament centred, the line in whole glyphs either side (a
|
|
31
|
+
# blank more after the ornament when a two-column glyph, under
|
|
32
|
+
# :cjk, would otherwise end half a glyph short).
|
|
33
|
+
def self.ornamented(line, ornament, width)
|
|
34
|
+
unit = Width.of(line)
|
|
35
|
+
middle = middle(ornament, width, unit)
|
|
36
|
+
room = width - Width.of(middle)
|
|
37
|
+
room!(ornament, middle, room, unit)
|
|
38
|
+
left = (room / unit / 2) * unit
|
|
39
|
+
Width.run(line, left) + middle + Width.run(line, room - left)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.middle(ornament, width, unit)
|
|
43
|
+
middle = " #{ornament} "
|
|
44
|
+
middle += " " until ((width - Width.of(middle)) % unit).zero?
|
|
45
|
+
middle
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.room!(ornament, middle, room, unit)
|
|
49
|
+
return if room >= 2 * unit
|
|
50
|
+
|
|
51
|
+
least = Width.of(middle) + (2 * unit)
|
|
52
|
+
raise ArgumentError, "ornament #{ornament.inspect} needs a " \
|
|
53
|
+
"width of #{least} at least"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# width: 78, ornament: nil, style: :light, ascii: false.
|
|
58
|
+
def self.rule(**) = Rule.build(**)
|
|
59
|
+
end
|