grmenu 4.1.0 → 5.0.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.
data/grmenu/image.rb ADDED
@@ -0,0 +1,339 @@
1
+ # frozen_string_literal: true
2
+
3
+ class GRmenu
4
+ # Decodificador PNG nativo en Ruby puro sin dependencias de gemas externas.
5
+ # Procesa las cabeceras IHDR, descomprime los chunks IDAT con Zlib,
6
+ # reconstruye las lineas de escaneo (scanlines) segun los 5 filtros estandar de PNG
7
+ # y escala la imagen para convertirla a celdas de texto TrueColor (24 bits).
8
+ class PNGDecoder
9
+ attr_reader :width, :height, :pixels
10
+
11
+ # Carga y parsea un archivo PNG desde disco
12
+ def self.load(filepath)
13
+ return nil unless filepath && File.exist?(filepath)
14
+ new.parse(File.binread(filepath))
15
+ rescue StandardError
16
+ nil
17
+ end
18
+
19
+ # Parsea los bytes en binario del archivo PNG
20
+ def parse(data)
21
+ # Valida la firma magica de 8 bytes de un archivo PNG
22
+ return nil unless data && data[0, 8] == "\x89PNG\r\n\x1a\n".b
23
+
24
+ offset = 8
25
+ idat_data = String.new("".b)
26
+ palette = nil
27
+
28
+ # Itera sobre los chunks PNG: [4 bytes longitud][4 bytes tipo][datos][4 bytes CRC]
29
+ while offset < data.bytesize
30
+ len = data[offset, 4].unpack1("N")
31
+ type = data[offset + 4, 4]
32
+ chunk_data = data[offset + 8, len]
33
+ offset += 12 + len # Avanza los 4 de len + 4 de tipo + longitud de datos + 4 de CRC
34
+
35
+ case type
36
+ when "IHDR"
37
+ # Cabecera principal: ancho, alto, profundidad de bits y tipo de color
38
+ @width, @height, @bit_depth, @color_type = chunk_data.unpack("NNCC")
39
+ when "PLTE"
40
+ # Paleta de colores para imagenes indexadas (tipo 3)
41
+ palette = chunk_data.bytes.each_slice(3).to_a
42
+ when "IDAT"
43
+ # Concatena bloques de datos comprimidos de la imagen
44
+ idat_data << chunk_data
45
+ when "IEND"
46
+ break
47
+ end
48
+ end
49
+
50
+ # Canales segun tipo de color PNG:
51
+ # 0: escala de grises (1), 2: RGB (3), 3: indexado (1), 4: gris+alfa (2), 6: RGBA (4)
52
+ channels = case @color_type
53
+ when 0 then 1
54
+ when 2 then 3
55
+ when 3 then 1
56
+ when 4 then 2
57
+ when 6 then 4
58
+ else return nil
59
+ end
60
+
61
+ bytes_per_pixel = [(@bit_depth * channels + 7) / 8, 1].max
62
+ row_stride = (@width * channels * @bit_depth + 7) / 8
63
+ scanline_length = row_stride + 1 # +1 byte inicial por cada scanline que define el tipo de filtro
64
+
65
+ # Descomprime el flujo de datos IDAT con zlib
66
+ raw_uncompressed = Zlib::Inflate.inflate(idat_data)
67
+ raw_bytes = raw_uncompressed.bytes
68
+ return nil if raw_bytes.length < (@height * scanline_length)
69
+
70
+ @pixels = Array.new(@height) { Array.new(@width) }
71
+ prev_reconstructed_row = Array.new(row_stride, 0)
72
+
73
+ # Reconstruccion de filtros PNG por cada linea de escaneo (RFC 2083):
74
+ # Filtro 0: None
75
+ # Filtro 1: Sub (reconstruye con el byte de la izquierda: a)
76
+ # Filtro 2: Up (reconstruye con el byte de arriba: b)
77
+ # Filtro 3: Average (promedio entre izquierda y arriba: (a + b) / 2)
78
+ # Filtro 4: Paeth (predictor lineal basado en izquierda, arriba y diagonal)
79
+ @height.times do |y|
80
+ row_start_index = y * scanline_length
81
+ filter_type = raw_bytes[row_start_index]
82
+ curr_filtered = raw_bytes[(row_start_index + 1)...(row_start_index + scanline_length)]
83
+ curr_reconstructed = Array.new(row_stride, 0)
84
+
85
+ row_stride.times do |i|
86
+ a = (i >= bytes_per_pixel) ? curr_reconstructed[i - bytes_per_pixel] : 0
87
+ b = prev_reconstructed_row[i]
88
+ c = (i >= bytes_per_pixel) ? prev_reconstructed_row[i - bytes_per_pixel] : 0
89
+ x = curr_filtered[i]
90
+
91
+ recon_val = case filter_type
92
+ when 0 then x
93
+ when 1 then (x + a) & 0xFF
94
+ when 2 then (x + b) & 0xFF
95
+ when 3 then (x + ((a + b) / 2)) & 0xFF
96
+ when 4
97
+ # Algoritmo predictor Paeth
98
+ p_val = a + b - c
99
+ pa = (p_val - a).abs
100
+ pb = (p_val - b).abs
101
+ pc = (p_val - c).abs
102
+ pr = if pa <= pb && pa <= pc
103
+ a
104
+ elsif pb <= pc
105
+ b
106
+ else
107
+ c
108
+ end
109
+ (x + pr) & 0xFF
110
+ else x
111
+ end
112
+ curr_reconstructed[i] = recon_val
113
+ end
114
+
115
+ prev_reconstructed_row = curr_reconstructed
116
+
117
+ # Mapea los bytes reconstruidos a la matriz de pixeles [R, G, B, A]
118
+ if @bit_depth == 16
119
+ @width.times do |x|
120
+ idx = x * channels * 2
121
+ r = curr_reconstructed[idx]
122
+ g = (channels >= 3) ? curr_reconstructed[idx + 2] : r
123
+ b = (channels >= 3) ? curr_reconstructed[idx + 4] : r
124
+ a = (channels == 4) ? curr_reconstructed[idx + 6] : (channels == 2 ? curr_reconstructed[idx + 2] : 255)
125
+ @pixels[y][x] = [r, g, b, a]
126
+ end
127
+ elsif @bit_depth == 8
128
+ @width.times do |x|
129
+ idx = x * channels
130
+ if @color_type == 3
131
+ p_idx = curr_reconstructed[idx]
132
+ rgb_val = palette ? (palette[p_idx] || [0, 0, 0]) : [0, 0, 0]
133
+ @pixels[y][x] = [rgb_val[0], rgb_val[1], rgb_val[2], 255]
134
+ else
135
+ r = curr_reconstructed[idx]
136
+ g = (channels >= 3) ? curr_reconstructed[idx + 1] : r
137
+ b = (channels >= 3) ? curr_reconstructed[idx + 2] : r
138
+ a = (channels == 4 || channels == 2) ? curr_reconstructed[idx + channels - 1] : 255
139
+ @pixels[y][x] = [r, g, b, a]
140
+ end
141
+ end
142
+ end
143
+ end
144
+ self
145
+ end
146
+
147
+ # Realiza un reescalado (resampling) de la matriz de pixeles por promedio de area (box average)
148
+ def resample(target_w, target_h)
149
+ resampled = Array.new(target_h) { Array.new(target_w) }
150
+ x_step = @width.to_f / target_w
151
+ y_step = @height.to_f / target_h
152
+
153
+ target_h.times do |ty|
154
+ sy_start = (ty * y_step).to_i
155
+ sy_end = [((ty + 1) * y_step).to_i, @height].min
156
+
157
+ target_w.times do |tx|
158
+ sx_start = (tx * x_step).to_i
159
+ sx_end = [((tx + 1) * x_step).to_i, @width].min
160
+
161
+ r_sum = g_sum = b_sum = a_sum = count = 0
162
+
163
+ (sy_start...sy_end).each do |sy|
164
+ (sx_start...sx_end).each do |sx|
165
+ pixel = @pixels[sy][sx]
166
+ next unless pixel
167
+ # Desprecia pixeles casi completamente transparentes (alfa <= 10)
168
+ if pixel[3] > 10
169
+ r_sum += pixel[0]
170
+ g_sum += pixel[1]
171
+ b_sum += pixel[2]
172
+ a_sum += pixel[3]
173
+ count += 1
174
+ end
175
+ end
176
+ end
177
+
178
+ if count > 0
179
+ resampled[ty][tx] = [
180
+ (r_sum / count).clamp(0, 255),
181
+ (g_sum / count).clamp(0, 255),
182
+ (b_sum / count).clamp(0, 255),
183
+ (a_sum / count).clamp(0, 255)
184
+ ]
185
+ else
186
+ mid_y = (sy_start + sy_end) / 2
187
+ mid_x = (sx_start + sx_end) / 2
188
+ resampled[ty][tx] = @pixels[mid_y][mid_x] || [0, 0, 0, 0]
189
+ end
190
+ end
191
+ end
192
+ resampled
193
+ end
194
+
195
+ # Convierte la imagen a lineas de texto ANSI TrueColor.
196
+ # Emplea el caracter de medio bloque superior '▀':
197
+ # El color frontal pinta el pixel superior y el color de fondo pinta el pixel inferior,
198
+ # permitiendo duplicar la resolucion vertical de la terminal en una sola linea de texto.
199
+ def render_ansi_lines(target_w = 40, target_h = nil)
200
+ target_h ||= [((@height.to_f / @width) * target_w).round, 2].max
201
+ target_h += 1 if target_h.odd? # Requiere altura par porque cada fila de texto empaqueta 2 pixeles verticales
202
+
203
+ grid = resample(target_w, target_h)
204
+ lines = []
205
+
206
+ (0...target_h).step(2) do |y|
207
+ row_top = grid[y]
208
+ row_bot = grid[y + 1] || grid[y]
209
+ line = String.new("")
210
+
211
+ target_w.times do |x|
212
+ r1, g1, b1, a1 = row_top[x]
213
+ r2, g2, b2, a2 = row_bot[x]
214
+
215
+ if a1 < 32 && a2 < 32
216
+ line << "\e[0m "
217
+ elsif a1 < 32
218
+ line << "\e[0m\e[38;2;#{r2};#{g2};#{b2}m▄"
219
+ elsif a2 < 32
220
+ line << "\e[0m\e[38;2;#{r1};#{g1};#{b1}m▀"
221
+ else
222
+ line << "\e[38;2;#{r1};#{g1};#{b1}m\e[48;2;#{r2};#{g2};#{b2}m▀"
223
+ end
224
+ end
225
+ line << "\e[0m"
226
+ lines << line
227
+ end
228
+
229
+ lines
230
+ end
231
+ end
232
+
233
+ # Carga una imagen y la renderiza en lineas ANSI TrueColor.
234
+ # Intenta usar ImageMagick (convert o magick) si esta disponible en el sistema operativo
235
+ # para aplicar escalado Lanczos de alta definicion; de lo contrario utiliza PNGDecoder nativo.
236
+ def self.load_and_render_image(filepath, width = 40, height = nil, max_cols = terminal_width)
237
+ return [] unless filepath && File.exist?(filepath)
238
+
239
+ req_w = [width.to_i, max_cols - 6].min
240
+ req_w = [req_w, 10].max
241
+
242
+ conv_bin = `which convert 2>/dev/null`.strip
243
+ conv_bin = `which magick 2>/dev/null`.strip if conv_bin.empty?
244
+
245
+ # Ruta con ImageMagick disponible en el sistema
246
+ if !conv_bin.empty?
247
+ info, _ = Open3.capture2("identify", "-format", "%w %h", filepath) rescue ["", nil]
248
+ orig_w, orig_h = info.strip.split.map(&:to_f)
249
+ aspect = (orig_w && orig_w > 0) ? (orig_h / orig_w) : 0.6
250
+ scale_h = height || (req_w * aspect).round
251
+ scale_h += 1 if scale_h.odd?
252
+ scale_h = [scale_h, 2].max
253
+
254
+ # Convierte la imagen a un flujo plano de bytes RGBA
255
+ cmd = [conv_bin, filepath, "-filter", "Lanczos", "-resize", "#{req_w}x#{scale_h}!", "-depth", "8", "rgba:-"]
256
+ stdout, status = Open3.capture2(*cmd) rescue [nil, nil]
257
+ if status && status.success? && stdout.bytesize == (req_w * scale_h * 4)
258
+ raw = stdout.bytes
259
+ lines = []
260
+ (0...scale_h).step(2) do |y|
261
+ line = String.new("")
262
+ req_w.times do |x|
263
+ top_idx = (y * req_w + x) * 4
264
+ bot_idx = ((y + 1) * req_w + x) * 4
265
+ r1, g1, b1, a1 = raw[top_idx, 4]
266
+ r2, g2, b2, a2 = raw[bot_idx, 4]
267
+
268
+ if a1 < 32 && a2 < 32
269
+ line << "\e[0m "
270
+ elsif a1 < 32
271
+ line << "\e[0m\e[38;2;#{r2};#{g2};#{b2}m▄"
272
+ elsif a2 < 32
273
+ line << "\e[0m\e[38;2;#{r1};#{g1};#{b1}m▀"
274
+ else
275
+ line << "\e[38;2;#{r1};#{g1};#{b1}m\e[48;2;#{r2};#{g2};#{b2}m▀"
276
+ end
277
+ end
278
+ line << "\e[0m"
279
+ lines << line
280
+ end
281
+ return lines
282
+ end
283
+ end
284
+
285
+ # Ruta de fallback: PNGDecoder nativo en Ruby puro
286
+ png = PNGDecoder.load(filepath)
287
+ return png.render_ansi_lines(req_w, height) if png
288
+
289
+ []
290
+ rescue StandardError
291
+ []
292
+ end
293
+
294
+ # Dibuja una imagen centrada en la terminal con marco decorativo opcional
295
+ def self.image(filepath, width: 40, height: nil, style: 3, color: "cyan", center: true)
296
+ term_w = terminal_width
297
+ raw_lines = load_and_render_image(filepath, width, height, term_w)
298
+ return nil if raw_lines.empty?
299
+
300
+ img_w = display_width(raw_lines.first)
301
+ box_w = img_w + 4
302
+ margin = (center && term_w > box_w) ? (" " * ((term_w - box_w) / 2)) : ""
303
+
304
+ if style && style > 0
305
+ border_cfg = BORDERS[style] || BORDERS[3]
306
+ is_rgb = (color.to_s.downcase == "rgb" || color.to_s.downcase == "rainbow" || color.to_s.downcase == "chroma")
307
+ color_code = is_rgb ? "" : ansi_color(color, 2)
308
+ reset_code = ansi_reset
309
+
310
+ h_top = border_cfg[:ht] || border_cfg[:h]
311
+ h_bot = border_cfg[:hb] || border_cfg[:h]
312
+ v_l = border_cfg[:vl] || border_cfg[:v]
313
+ v_r = border_cfg[:vr] || border_cfg[:v]
314
+
315
+ top_fill = (h_top * ((box_w - 2).to_f / h_top.length).ceil)[0...(box_w - 2)]
316
+ bot_fill = (h_bot * ((box_w - 2).to_f / h_bot.length).ceil)[0...(box_w - 2)]
317
+
318
+ if is_rgb
319
+ Kernel.print("#{margin}#{Color.rgb("#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}")}\r\n")
320
+ raw_lines.each do |line|
321
+ Kernel.print("#{margin}#{Color.rgb(v_l)} #{line} #{Color.rgb(v_r)}\r\n")
322
+ end
323
+ Kernel.print("#{margin}#{Color.rgb("#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}")}\r\n")
324
+ else
325
+ Kernel.print("#{margin}#{color_code}#{border_cfg[:tl]}#{top_fill}#{border_cfg[:tr]}#{reset_code}\r\n")
326
+ raw_lines.each do |line|
327
+ Kernel.print("#{margin}#{color_code}#{v_l}#{reset_code} #{line} #{color_code}#{v_r}#{reset_code}\r\n")
328
+ end
329
+ Kernel.print("#{margin}#{color_code}#{border_cfg[:bl]}#{bot_fill}#{border_cfg[:br]}#{reset_code}\r\n")
330
+ end
331
+ else
332
+ raw_lines.each do |line|
333
+ Kernel.print("#{margin}#{line}\r\n")
334
+ end
335
+ end
336
+
337
+ true
338
+ end
339
+ end