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