@emaxe/tuigram 1.0.1 → 1.2.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.
@@ -0,0 +1,343 @@
1
+ /**
2
+ * Модуль обработки и рендеринга изображений в терминале (Unicode Half-Block).
3
+ * Преобразует растровые изображения (JPEG, PNG, PhotoStrippedSize) в псевдографику
4
+ * высокого разрешения с разметкой blessed и 24-битными hex-цветами.
5
+ */
6
+
7
+ import jpegJs from "jpeg-js";
8
+ import { PNG } from "pngjs";
9
+
10
+ /** Заголовок стандартного JPEG для распаковки Telegram PhotoStrippedSize. */
11
+ const JPEG_HEADER = Buffer.from([
12
+ 0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01,
13
+ 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xff, 0xdb, 0x00, 0x43,
14
+ 0x00, 0x28, 0x1c, 0x1e, 0x23, 0x1e, 0x19, 0x28, 0x23, 0x21, 0x23, 0x2d,
15
+ 0x2b, 0x28, 0x30, 0x3c, 0x64, 0x41, 0x3c, 0x37, 0x37, 0x3c, 0x7b, 0x58,
16
+ 0x5d, 0x49, 0x64, 0x91, 0x80, 0x99, 0x96, 0x8f, 0x80, 0x8c, 0x8a, 0xa0,
17
+ 0xb4, 0xe6, 0xc3, 0xa0, 0xaa, 0xda, 0xad, 0x8a, 0x8c, 0xc8, 0xff, 0xcb,
18
+ 0xda, 0xee, 0xf5, 0xff, 0xff, 0xff, 0x9b, 0xc1, 0xff, 0xff, 0xf7, 0xfa,
19
+ 0xff, 0xe6, 0xfd, 0xff, 0xf8, 0xff, 0xdb, 0x00, 0x43, 0x01, 0x2b, 0x2d,
20
+ 0x2d, 0x3c, 0x35, 0x3c, 0x75, 0x41, 0x41, 0x75, 0xf8, 0xa5, 0x8c, 0xa5,
21
+ 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8,
22
+ 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8,
23
+ 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8,
24
+ 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xff, 0xc0, 0x00,
25
+ 0x11, 0x08, 0x00, 0x00, 0x00, 0x00, 0x03, 0x01, 0x22, 0x00, 0x02, 0x11,
26
+ 0x01, 0x03, 0x11, 0x01, 0xff, 0xc4, 0x00, 0x1f, 0x00, 0x00, 0x01, 0x05,
27
+ 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
28
+ 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a,
29
+ 0x0b, 0xff, 0xc4, 0x00, 0xb5, 0x10, 0x00, 0x02, 0x01, 0x03, 0x03, 0x02,
30
+ 0x04, 0x03, 0x05, 0x05, 0x04, 0x04, 0x00, 0x00, 0x01, 0x7d, 0x01, 0x02,
31
+ 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, 0x31, 0x41, 0x06, 0x13, 0x51,
32
+ 0x61, 0x07, 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xa1, 0x08, 0x23, 0x42,
33
+ 0xb1, 0xc1, 0x15, 0x52, 0xd1, 0xf0, 0x24, 0x33, 0x62, 0x72, 0x82, 0x09,
34
+ 0x0a, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a,
35
+ 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x43, 0x44, 0x45, 0x46, 0x47,
36
+ 0x48, 0x49, 0x4a, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5a, 0x63,
37
+ 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6a, 0x73, 0x74, 0x75, 0x76, 0x77,
38
+ 0x78, 0x79, 0x7a, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8a, 0x92,
39
+ 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9a, 0xa2, 0xa3, 0xa4, 0xa5,
40
+ 0xa6, 0xa7, 0xa8, 0xa9, 0xaa, 0xb2, 0xb3, 0xb4, 0xb5, 0xb6, 0xb7, 0xb8,
41
+ 0xb9, 0xba, 0xc2, 0xc3, 0xc4, 0xc5, 0xc6, 0xc7, 0xc8, 0xc9, 0xca, 0xd2,
42
+ 0xd3, 0xd4, 0xd5, 0xd6, 0xd7, 0xd8, 0xd9, 0xda, 0xe1, 0xe2, 0xe3, 0xe4,
43
+ 0xe5, 0xe6, 0xe7, 0xe8, 0xe9, 0xea, 0xf1, 0xf2, 0xf3, 0xf4, 0xf5, 0xf6,
44
+ 0xf7, 0xf8, 0xf9, 0xfa, 0xff, 0xc4, 0x00, 0x1f, 0x01, 0x00, 0x03, 0x01,
45
+ 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00,
46
+ 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a,
47
+ 0x0b, 0xff, 0xc4, 0x00, 0xb5, 0x11, 0x00, 0x02, 0x01, 0x02, 0x04, 0x04,
48
+ 0x03, 0x04, 0x07, 0x05, 0x04, 0x04, 0x00, 0x01, 0x02, 0x77, 0x00, 0x01,
49
+ 0x02, 0x03, 0x11, 0x04, 0x05, 0x21, 0x31, 0x06, 0x12, 0x41, 0x51, 0x07,
50
+ 0x61, 0x71, 0x13, 0x22, 0x32, 0x81, 0x08, 0x14, 0x42, 0x91, 0xa1, 0xb1,
51
+ 0xc1, 0x09, 0x23, 0x33, 0x52, 0xf0, 0x15, 0x62, 0x72, 0xd1, 0x0a, 0x16,
52
+ 0x24, 0x34, 0xe1, 0x25, 0xf1, 0x17, 0x18, 0x19, 0x1a, 0x26, 0x27, 0x28,
53
+ 0x29, 0x2a, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x43, 0x44, 0x45, 0x46,
54
+ 0x47, 0x48, 0x49, 0x4a, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5a,
55
+ 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6a, 0x73, 0x74, 0x75, 0x76,
56
+ 0x77, 0x78, 0x79, 0x7a, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89,
57
+ 0x8a, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9a, 0xa2, 0xa3,
58
+ 0xa4, 0xa5, 0xa6, 0xa7, 0xa8, 0xa9, 0xaa, 0xb2, 0xb3, 0xb4, 0xb5, 0xb6,
59
+ 0xb7, 0xb8, 0xb9, 0xba, 0xc2, 0xc3, 0xc4, 0xc5, 0xc6, 0xc7, 0xc8, 0xc9,
60
+ 0xca, 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7, 0xd8, 0xd9, 0xda, 0xe2, 0xe3,
61
+ 0xe4, 0xe5, 0xe6, 0xe7, 0xe8, 0xe9, 0xea, 0xf2, 0xf3, 0xf4, 0xf5, 0xf6,
62
+ 0xf7, 0xf8, 0xf9, 0xfa, 0xff, 0xda, 0x00, 0x0c, 0x03, 0x01, 0x00, 0x02,
63
+ 0x11, 0x03, 0x11, 0x00, 0x3f, 0x00
64
+ ]);
65
+
66
+ /** Завершающий маркер JPEG (End Of Image). */
67
+ const JPEG_FOOTER = Buffer.from([0xff, 0xd9]);
68
+
69
+ /**
70
+ * Память кэша для готовых строк псевдографики.
71
+ * @type {Map<string, string>}
72
+ */
73
+ export const imagePreviewCache = new Map();
74
+ const MAX_CACHE_SIZE = 500;
75
+
76
+ /**
77
+ * Распаковывает PhotoStrippedSize Telegram в валидный JPEG буфер.
78
+ * @param {Buffer|Uint8Array} stripped
79
+ * @returns {Buffer}
80
+ */
81
+ export function strippedPhotoToJpg(stripped) {
82
+ if (!stripped || stripped.length < 3) {
83
+ return Buffer.isBuffer(stripped) ? stripped : Buffer.from(stripped || []);
84
+ }
85
+ const buf = Buffer.isBuffer(stripped) ? stripped : Buffer.from(stripped);
86
+ if (buf[0] !== 1) {
87
+ return buf;
88
+ }
89
+ const header = Buffer.from(JPEG_HEADER);
90
+ header[164] = buf[1];
91
+ header[166] = buf[2];
92
+ return Buffer.concat([header, buf.subarray(3), JPEG_FOOTER]);
93
+ }
94
+
95
+ /**
96
+ * Декодирует буфер изображения (JPEG или PNG) в сырой RGBA буфер.
97
+ * @param {Buffer|Uint8Array} buffer
98
+ * @param {string} [mimeType]
99
+ * @returns {{ width: number, height: number, data: Uint8Array }}
100
+ */
101
+ export function decodeImageBuffer(buffer, mimeType = "") {
102
+ if (!buffer || buffer.length === 0) {
103
+ throw new Error("Пустой буфер изображения");
104
+ }
105
+
106
+ const buf = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
107
+
108
+ // Определение формата по magic bytes или mimeType
109
+ const isPng = (buf.length >= 8 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47)
110
+ || mimeType.includes("png");
111
+
112
+ if (isPng) {
113
+ const png = PNG.sync.read(buf);
114
+ return {
115
+ width: png.width,
116
+ height: png.height,
117
+ data: png.data,
118
+ };
119
+ }
120
+
121
+ // По умолчанию считаем JPEG
122
+ const decoded = jpegJs.decode(buf, { useTArray: true, formatAsRGBA: true });
123
+ return {
124
+ width: decoded.width,
125
+ height: decoded.height,
126
+ data: decoded.data,
127
+ };
128
+ }
129
+
130
+ /**
131
+ * Рассчитывает целевые размеры изображения в терминале с сохранением пропорций.
132
+ * Каждый терминальный ряд Half-Block вмещает 2 вертикальных субпикселя.
133
+ * @param {number} srcW Исходная ширина в пикселях
134
+ * @param {number} srcH Исходная высота в пикселях
135
+ * @param {number} [maxWidth=36] Максимальная ширина в колонках символов
136
+ * @param {number} [maxHeight=14] Максимальная высота в строках терминала
137
+ * @returns {{ dstW: number, dstH: number, rows: number }}
138
+ */
139
+ export function calculateTargetDimensions(srcW, srcH, maxWidth = 36, maxHeight = 14) {
140
+ const safeW = Math.max(1, srcW || 1);
141
+ const safeH = Math.max(1, srcH || 1);
142
+ const maxPixelH = Math.max(2, maxHeight * 2);
143
+
144
+ const aspect = safeW / safeH;
145
+ const maxAspect = maxWidth / maxPixelH;
146
+
147
+ let dstW = maxWidth;
148
+ let dstPixelH = maxPixelH;
149
+
150
+ if (aspect >= maxAspect) {
151
+ dstW = Math.min(maxWidth, safeW);
152
+ dstPixelH = Math.max(2, Math.round(dstW / aspect));
153
+ } else {
154
+ dstPixelH = Math.min(maxPixelH, safeH * 2);
155
+ dstW = Math.max(1, Math.round(dstPixelH * aspect));
156
+ }
157
+
158
+ // Округляем пиксельную высоту до четного числа (для парных субпикселей ▀)
159
+ const rows = Math.max(1, Math.ceil(dstPixelH / 2));
160
+ dstPixelH = rows * 2;
161
+
162
+ return { dstW, dstH: dstPixelH, rows };
163
+ }
164
+
165
+ /**
166
+ * Масштабирует RGBA буфер методом билинейной интерполяции.
167
+ * @param {Uint8Array} src RGBA буфер исходного изображения
168
+ * @param {number} srcW Исходная ширина
169
+ * @param {number} srcH Исходная высота
170
+ * @param {number} dstW Целевая ширина
171
+ * @param {number} dstH Целевая высота
172
+ * @returns {Uint8Array} Результирующий RGBA буфер длины dstW * dstH * 4
173
+ */
174
+ export function resizeRgba(src, srcW, srcH, dstW, dstH) {
175
+ const dst = new Uint8Array(dstW * dstH * 4);
176
+ if (srcW === dstW && srcH === dstH) {
177
+ dst.set(src);
178
+ return dst;
179
+ }
180
+
181
+ const xRatio = srcW / dstW;
182
+ const yRatio = srcH / dstH;
183
+
184
+ for (let dy = 0; dy < dstH; dy++) {
185
+ const srcY = (dy + 0.5) * yRatio - 0.5;
186
+ const y0 = Math.max(0, Math.min(srcH - 1, Math.floor(srcY)));
187
+ const y1 = Math.max(0, Math.min(srcH - 1, y0 + 1));
188
+ const yWeight = Math.max(0, Math.min(1, srcY - y0));
189
+
190
+ const y0Offset = y0 * srcW * 4;
191
+ const y1Offset = y1 * srcW * 4;
192
+ const dstRowOffset = dy * dstW * 4;
193
+
194
+ for (let dx = 0; dx < dstW; dx++) {
195
+ const srcX = (dx + 0.5) * xRatio - 0.5;
196
+ const x0 = Math.max(0, Math.min(srcW - 1, Math.floor(srcX)));
197
+ const x1 = Math.max(0, Math.min(srcW - 1, x0 + 1));
198
+ const xWeight = Math.max(0, Math.min(1, srcX - x0));
199
+
200
+ const p00 = y0Offset + x0 * 4;
201
+ const p10 = y0Offset + x1 * 4;
202
+ const p01 = y1Offset + x0 * 4;
203
+ const p11 = y1Offset + x1 * 4;
204
+
205
+ const dstOffset = dstRowOffset + dx * 4;
206
+
207
+ // Интерполяция 4 каналов: R, G, B, A
208
+ for (let c = 0; c < 4; c++) {
209
+ const top = (1 - xWeight) * src[p00 + c] + xWeight * src[p10 + c];
210
+ const bot = (1 - xWeight) * src[p01 + c] + xWeight * src[p11 + c];
211
+ dst[dstOffset + c] = Math.round((1 - yWeight) * top + yWeight * bot);
212
+ }
213
+ }
214
+ }
215
+
216
+ return dst;
217
+ }
218
+
219
+ /**
220
+ * Преобразует компоненты цвета R, G, B в hex-строку вида "#rrggbb".
221
+ * @param {number} r
222
+ * @param {number} g
223
+ * @param {number} b
224
+ * @returns {string}
225
+ */
226
+ export function rgbaToHex(r, g, b) {
227
+ const hexR = (r < 16 ? "0" : "") + r.toString(16);
228
+ const hexG = (g < 16 ? "0" : "") + g.toString(16);
229
+ const hexB = (b < 16 ? "0" : "") + b.toString(16);
230
+ return `#${hexR}${hexG}${hexB}`;
231
+ }
232
+
233
+ /**
234
+ * Преобразует масштабированный RGBA буфер в многострочный текст Blessed
235
+ * с использованием символов верхнего полублока (▀ U+2580).
236
+ *
237
+ * Верхний пиксель ячейки задаётся через тег цвета текста (`{ #rrggbb-fg }`),
238
+ * нижний пиксель — через тег цвета фона (`{ #rrggbb-bg }`).
239
+ *
240
+ * @param {Uint8Array} data RGBA буфер
241
+ * @param {number} width Ширина в символах/пикселях
242
+ * @param {number} height Высота в пикселях (должна быть четной, = rows * 2)
243
+ * @returns {string}
244
+ */
245
+ export function rgbaToHalfBlockBlessed(data, width, height) {
246
+ const rows = Math.floor(height / 2);
247
+ const lines = [];
248
+
249
+ for (let r = 0; r < rows; r++) {
250
+ let line = "";
251
+ let currentFg = null;
252
+ let currentBg = null;
253
+
254
+ const topRowOffset = (r * 2) * width * 4;
255
+ const botRowOffset = (r * 2 + 1) * width * 4;
256
+
257
+ for (let x = 0; x < width; x++) {
258
+ const topOffset = topRowOffset + x * 4;
259
+ const botOffset = botRowOffset + x * 4;
260
+
261
+ const topHex = rgbaToHex(data[topOffset], data[topOffset + 1], data[topOffset + 2]);
262
+ const botHex = rgbaToHex(data[botOffset], data[botOffset + 1], data[botOffset + 2]);
263
+
264
+ if (topHex !== currentFg) {
265
+ line += `{${topHex}-fg}`;
266
+ currentFg = topHex;
267
+ }
268
+ if (botHex !== currentBg) {
269
+ line += `{${botHex}-bg}`;
270
+ currentBg = botHex;
271
+ }
272
+
273
+ line += "▀";
274
+ }
275
+
276
+ if (currentFg || currentBg) {
277
+ line += "{/}";
278
+ }
279
+
280
+ lines.push(line);
281
+ }
282
+
283
+ return lines.join("\n");
284
+ }
285
+
286
+ /**
287
+ * Выполняет полный цикл декодирования, масштабирования и рендеринга изображения в Blessed-строку.
288
+ * @param {Buffer|Uint8Array} buffer Исходный буфер изображения (JPEG или PNG)
289
+ * @param {object} [options]
290
+ * @param {string} [options.mimeType=""] MIME-тип изображения
291
+ * @param {number} [options.maxWidth=36] Максимальная ширина
292
+ * @param {number} [options.maxHeight=14] Максимальная высота
293
+ * @param {string} [options.cacheKey] Ключ для сохранения в кэш
294
+ * @returns {string}
295
+ */
296
+ export function renderImageBuffer(buffer, { mimeType = "", maxWidth = 36, maxHeight = 14, cacheKey } = {}) {
297
+ if (cacheKey && imagePreviewCache.has(cacheKey)) {
298
+ return imagePreviewCache.get(cacheKey);
299
+ }
300
+
301
+ try {
302
+ const decoded = decodeImageBuffer(buffer, mimeType);
303
+ const { dstW, dstH } = calculateTargetDimensions(decoded.width, decoded.height, maxWidth, maxHeight);
304
+ const resized = resizeRgba(decoded.data, decoded.width, decoded.height, dstW, dstH);
305
+ const blessedText = rgbaToHalfBlockBlessed(resized, dstW, dstH);
306
+
307
+ if (cacheKey) {
308
+ if (imagePreviewCache.size >= MAX_CACHE_SIZE) {
309
+ const firstKey = imagePreviewCache.keys().next().value;
310
+ imagePreviewCache.delete(firstKey);
311
+ }
312
+ imagePreviewCache.set(cacheKey, blessedText);
313
+ }
314
+
315
+ return blessedText;
316
+ } catch {
317
+ return "";
318
+ }
319
+ }
320
+
321
+ /**
322
+ * Синхронно распаковывает и рендерит PhotoStrippedSize Telegram в Blessed Half-Block строку.
323
+ * @param {Buffer|Uint8Array} strippedBytes Байты PhotoStrippedSize
324
+ * @param {object} [options]
325
+ * @param {number} [options.maxWidth=36]
326
+ * @param {number} [options.maxHeight=14]
327
+ * @param {string} [options.cacheKey]
328
+ * @returns {string}
329
+ */
330
+ export function renderStrippedThumbnail(strippedBytes, { maxWidth = 36, maxHeight = 14, cacheKey } = {}) {
331
+ if (!strippedBytes || strippedBytes.length < 3) return "";
332
+
333
+ if (cacheKey && imagePreviewCache.has(cacheKey)) {
334
+ return imagePreviewCache.get(cacheKey);
335
+ }
336
+
337
+ try {
338
+ const jpgBuf = strippedPhotoToJpg(strippedBytes);
339
+ return renderImageBuffer(jpgBuf, { mimeType: "image/jpeg", maxWidth, maxHeight, cacheKey });
340
+ } catch {
341
+ return "";
342
+ }
343
+ }