telegix 1.1.2 → 1.1.3

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.
package/lib/table.js ADDED
@@ -0,0 +1,1124 @@
1
+ /**
2
+ * Telegix - Table Generator & Formatting Engine
3
+ * Generates beautiful formatted ASCII / Unicode box tables for Telegram HTML (<pre>)
4
+ * and structured InputRichBlockTable / RichBlockTable for Telegram Bot API 10.3+.
5
+ * @module telegix/table
6
+ */
7
+
8
+ import { escapeHtml } from './format.js';
9
+
10
+ // Table Border Box Styles
11
+ const STYLES = {
12
+ box: {
13
+ topLeft: '┌',
14
+ topMid: '┬',
15
+ topRight: '┐',
16
+ midLeft: '├',
17
+ midMid: '┼',
18
+ midRight: '┤',
19
+ bottomLeft: '└',
20
+ bottomMid: '┴',
21
+ bottomRight: '┘',
22
+ horizontal: '─',
23
+ vertical: '│',
24
+ },
25
+ ascii: {
26
+ topLeft: '+',
27
+ topMid: '+',
28
+ topRight: '+',
29
+ midLeft: '+',
30
+ midMid: '+',
31
+ midRight: '+',
32
+ bottomLeft: '+',
33
+ bottomMid: '+',
34
+ bottomRight: '+',
35
+ horizontal: '-',
36
+ vertical: '|',
37
+ },
38
+ compact: {
39
+ topLeft: '',
40
+ topMid: ' ',
41
+ topRight: '',
42
+ midLeft: '',
43
+ midMid: '┼',
44
+ midRight: '',
45
+ bottomLeft: '',
46
+ bottomMid: '',
47
+ bottomRight: '',
48
+ horizontal: '─',
49
+ vertical: '│',
50
+ },
51
+ clean: {
52
+ topLeft: '',
53
+ topMid: '',
54
+ topRight: '',
55
+ midLeft: '',
56
+ midMid: ' ',
57
+ midRight: '',
58
+ bottomLeft: '',
59
+ bottomMid: '',
60
+ bottomRight: '',
61
+ horizontal: '─',
62
+ vertical: ' ',
63
+ },
64
+ card: {
65
+ topLeft: '╭',
66
+ topMid: '┬',
67
+ topRight: '╮',
68
+ midLeft: '├',
69
+ midMid: '┼',
70
+ midRight: '┤',
71
+ bottomLeft: '╰',
72
+ bottomMid: '┴',
73
+ bottomRight: '╯',
74
+ horizontal: '─',
75
+ vertical: '│',
76
+ },
77
+ };
78
+
79
+ /**
80
+ * Calculate visual string length (supports basic emojis and numbers)
81
+ * @param {any} val
82
+ * @returns {number}
83
+ */
84
+ function visualLength(val) {
85
+ if (val === null || val === undefined) return 0;
86
+ return String(val).length;
87
+ }
88
+
89
+ /**
90
+ * Pad a string to target width with alignment
91
+ * @param {any} val
92
+ * @param {number} width
93
+ * @param {'left'|'center'|'right'} [align='left']
94
+ * @returns {string}
95
+ */
96
+ function pad(val, width, align = 'left') {
97
+ const str = val === null || val === undefined ? '' : String(val);
98
+ const diff = width - visualLength(str);
99
+ if (diff <= 0) return str;
100
+
101
+ if (align === 'right') {
102
+ return ' '.repeat(diff) + str;
103
+ }
104
+ if (align === 'center') {
105
+ const left = Math.floor(diff / 2);
106
+ const right = diff - left;
107
+ return ' '.repeat(left) + str + ' '.repeat(right);
108
+ }
109
+ return str + ' '.repeat(diff);
110
+ }
111
+
112
+ /**
113
+ * Escape string for SVG XML
114
+ * @param {any} val
115
+ * @returns {string}
116
+ */
117
+ function escapeSvg(val) {
118
+ if (val === null || val === undefined) return '';
119
+ return String(val)
120
+ .replace(/&/g, '&amp;')
121
+ .replace(/</g, '&lt;')
122
+ .replace(/>/g, '&gt;')
123
+ .replace(/"/g, '&quot;')
124
+ .replace(/'/g, '&apos;');
125
+ }
126
+
127
+ /**
128
+ * Table builder class for Telegram messages and Rich Blocks
129
+ */
130
+ export class Table {
131
+ /**
132
+ * @param {Array<string>|object} [headersOrOptions={}]
133
+ * @param {Array<Array<any>>} [rows=[]]
134
+ * @param {object} [options={}]
135
+ */
136
+ constructor(headersOrOptions = {}, rows = [], options = {}) {
137
+ this.type = 'table';
138
+ let opts = {};
139
+ if (Array.isArray(headersOrOptions)) {
140
+ this.headers = [...headersOrOptions];
141
+ this.rows = Array.isArray(rows) ? rows.map((r) => [...r]) : [];
142
+ opts = options || {};
143
+ } else {
144
+ opts = headersOrOptions || {};
145
+ this.headers = opts.headers ? [...opts.headers] : [];
146
+ this.rows = opts.rows ? opts.rows.map((r) => [...r]) : [];
147
+ }
148
+
149
+ this.is_bordered = Boolean(opts.is_bordered ?? opts.isBordered ?? true);
150
+ this.is_compact = Boolean(opts.is_compact ?? opts.isCompact ?? false);
151
+ this.is_striped = Boolean(opts.is_striped ?? opts.isStriped ?? false);
152
+ this.caption = opts.caption || '';
153
+ this._style = opts.style || 'card';
154
+ this._title = opts.title || '';
155
+ this._alignments = opts.alignments ? [...opts.alignments] : [];
156
+ this.col1Width = opts.col1Width;
157
+ this.asImage = Boolean(opts.asImage || opts.photo || opts.image);
158
+ this._cardOptions = { ...opts };
159
+ }
160
+
161
+ /**
162
+ * Set headers
163
+ * @param {Array<string>|...string} headers
164
+ * @returns {this}
165
+ */
166
+ header(...headers) {
167
+ if (headers.length === 1 && Array.isArray(headers[0])) {
168
+ this.headers = [...headers[0]];
169
+ } else {
170
+ this.headers = headers.flat();
171
+ }
172
+ return this;
173
+ }
174
+
175
+ /**
176
+ * Set headers (alias)
177
+ * @param {Array<string>} headers
178
+ * @returns {this}
179
+ */
180
+ setHeaders(headers) {
181
+ this.headers = Array.isArray(headers) ? [...headers] : [];
182
+ return this;
183
+ }
184
+
185
+ /**
186
+ * Add a row
187
+ * @param {Array<any>|...any} cells
188
+ * @returns {this}
189
+ */
190
+ row(...cells) {
191
+ if (cells.length === 1 && Array.isArray(cells[0])) {
192
+ this.rows.push([...cells[0]]);
193
+ } else {
194
+ this.rows.push(cells.flat());
195
+ }
196
+ return this;
197
+ }
198
+
199
+ /**
200
+ * Add multiple rows
201
+ * @param {Array<Array<any>>} rows
202
+ * @returns {this}
203
+ */
204
+ addRows(rows) {
205
+ if (Array.isArray(rows)) {
206
+ for (const r of rows) {
207
+ this.row(r);
208
+ }
209
+ }
210
+ return this;
211
+ }
212
+
213
+ /**
214
+ * Set table compact mode (Telegram Bot API 10.3)
215
+ * @param {boolean} [isCompact=true]
216
+ * @returns {this}
217
+ */
218
+ compact(isCompact = true) {
219
+ this.is_compact = Boolean(isCompact);
220
+ return this;
221
+ }
222
+
223
+ /**
224
+ * Set visual style ('box' | 'ascii' | 'compact' | 'clean' | 'markdown')
225
+ * @param {string} styleName
226
+ * @returns {this}
227
+ */
228
+ style(styleName) {
229
+ this._style = styleName;
230
+ return this;
231
+ }
232
+
233
+ /**
234
+ * Set optional table title
235
+ * @param {string} title
236
+ * @returns {this}
237
+ */
238
+ title(title) {
239
+ this._title = title;
240
+ return this;
241
+ }
242
+
243
+ /**
244
+ * Set column alignment
245
+ * @param {number} colIndex
246
+ * @param {'left'|'center'|'right'} align
247
+ * @returns {this}
248
+ */
249
+ columnAlign(colIndex, align) {
250
+ this._alignments[colIndex] = align;
251
+ return this;
252
+ }
253
+
254
+ /**
255
+ * Set alignments for all columns
256
+ * @param {Array<'left'|'center'|'right'>} aligns
257
+ * @returns {this}
258
+ */
259
+ alignments(aligns) {
260
+ this._alignments = Array.isArray(aligns) ? [...aligns] : [];
261
+ return this;
262
+ }
263
+
264
+ /**
265
+ * Render table as plain formatted monospaced text
266
+ * @param {object} [options]
267
+ * @returns {string}
268
+ */
269
+ format(options = {}) {
270
+ const opts = typeof options === 'string' ? { style: options } : (options || {});
271
+ return Table.format(this.headers, this.rows, {
272
+ style: opts.style || this._style,
273
+ isCompact: opts.is_compact ?? opts.isCompact ?? this.is_compact,
274
+ alignments: opts.alignments || this._alignments,
275
+ title: opts.title || this._title,
276
+ ...opts,
277
+ });
278
+ }
279
+
280
+ /**
281
+ * String coercion
282
+ */
283
+ toString() {
284
+ return this.format();
285
+ }
286
+
287
+ /**
288
+ * Render table as HTML wrapped in <pre> tags for Telegram
289
+ * @param {object} [options]
290
+ * @returns {string}
291
+ */
292
+ toHtml(options = {}) {
293
+ const formatted = this.format(options);
294
+ const title = options.title || this._title;
295
+ const titleHtml = title ? `<b>${escapeHtml(title)}</b>\n\n` : '';
296
+ return `${titleHtml}<pre>${escapeHtml(formatted)}</pre>`;
297
+ }
298
+
299
+ /**
300
+ * Render table as Markdown (or inside ``` code block)
301
+ * @param {object} [options]
302
+ * @returns {string}
303
+ */
304
+ toMarkdown(options = {}) {
305
+ return Table.markdown(this.headers, this.rows, options);
306
+ }
307
+
308
+ /**
309
+ * Set table bordered mode (Telegram Bot API 10.3 / Card style)
310
+ * @param {boolean} [isBordered=true]
311
+ * @returns {this}
312
+ */
313
+ bordered(isBordered = true) {
314
+ this.is_bordered = Boolean(isBordered);
315
+ return this;
316
+ }
317
+
318
+ /**
319
+ * Set table striped mode (Telegram Bot API 10.3)
320
+ * @param {boolean} [isStriped=true]
321
+ * @returns {this}
322
+ */
323
+ striped(isStriped = true) {
324
+ this.is_striped = Boolean(isStriped);
325
+ return this;
326
+ }
327
+
328
+ /**
329
+ * Convert table data into 2D array of RichBlockTableCell for Telegram Bot API 10.3
330
+ * @returns {Array<Array<object>>}
331
+ */
332
+ toCells() {
333
+ const cells = [];
334
+ if (this.headers.length > 0) {
335
+ cells.push(
336
+ this.headers.map((h, i) => ({
337
+ text: String(h ?? ''),
338
+ is_header: true,
339
+ align: this._alignments[i] || 'center',
340
+ valign: 'middle',
341
+ }))
342
+ );
343
+ }
344
+ for (const row of this.rows) {
345
+ cells.push(
346
+ row.map((cell, i) => {
347
+ if (cell && typeof cell === 'object' && cell.text !== undefined) {
348
+ return {
349
+ align: this._alignments[i] || 'left',
350
+ valign: 'middle',
351
+ ...cell,
352
+ };
353
+ }
354
+ return {
355
+ text: String(cell ?? ''),
356
+ align: this._alignments[i] || 'left',
357
+ valign: 'middle',
358
+ };
359
+ })
360
+ );
361
+ }
362
+ return cells;
363
+ }
364
+
365
+ /**
366
+ * Render table as SVG vector graphic matching the Telegram Bot Card Table UI
367
+ * (dark rounded container, grid borders, headers, and blue clickable links)
368
+ * @param {object} [options]
369
+ * @returns {string} SVG XML markup string
370
+ */
371
+ toCardSvg(options = {}) {
372
+ const width = Number(options.width || 420);
373
+ const maxLabelLen = Math.max(
374
+ ...this.rows.map((r) => String(r[0] ?? '').length),
375
+ this.headers[0] ? String(this.headers[0]).length : 0,
376
+ 10
377
+ );
378
+ const calculatedCol1 = Math.max(130, Math.min(220, maxLabelLen * 9 + 30));
379
+ const col1Width = Number(options.col1Width || this.col1Width || calculatedCol1);
380
+ const headerHeight = Number(options.headerHeight || 38);
381
+ const rowHeight = Number(options.rowHeight || 36);
382
+ const hasHeader = this.headers.length > 0;
383
+ const headerH = hasHeader ? headerHeight : 0;
384
+ const totalHeight = headerH + (this.rows.length * rowHeight);
385
+
386
+ const bgColor = options.bgColor || '#18222d';
387
+ const borderColor = options.borderColor || '#2b3d4f';
388
+ const headerBg = options.headerBg || '#1c2836';
389
+ const labelColor = options.labelColor || '#90a4b7';
390
+ const valColor = options.valueColor || '#ffffff';
391
+ const linkColor = options.linkColor || '#5288c1';
392
+ const fontFamily = options.fontFamily || '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif';
393
+
394
+ let svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${totalHeight}" viewBox="0 0 ${width} ${totalHeight}">\n`;
395
+ svg += ` <style>\n`;
396
+ svg += ` .t-lbl { font-family: ${fontFamily}; font-size: 14px; fill: ${labelColor}; font-weight: 400; }\n`;
397
+ svg += ` .t-val { font-family: ${fontFamily}; font-size: 14px; fill: ${valColor}; font-weight: 400; }\n`;
398
+ svg += ` .t-val-bold { font-family: ${fontFamily}; font-size: 14px; fill: ${valColor}; font-weight: 600; }\n`;
399
+ svg += ` .t-link { font-family: ${fontFamily}; font-size: 14px; fill: ${linkColor}; font-weight: 500; cursor: pointer; }\n`;
400
+ svg += ` .t-hdr { font-family: ${fontFamily}; font-size: 14px; fill: #ffffff; font-weight: 700; }\n`;
401
+ svg += ` </style>\n`;
402
+
403
+ // Outer rounded card
404
+ svg += ` <rect x="0.5" y="0.5" width="${width - 1}" height="${totalHeight - 1}" rx="12" ry="12" fill="${bgColor}" stroke="${borderColor}" stroke-width="1"/>\n`;
405
+
406
+ if (hasHeader) {
407
+ const clipId = `clip-top-${Math.floor(Math.random() * 1000000)}`;
408
+ svg += ` <clipPath id="${clipId}">\n`;
409
+ svg += ` <rect x="0.5" y="0.5" width="${width - 1}" height="${totalHeight - 1}" rx="12" ry="12" />\n`;
410
+ svg += ` </clipPath>\n`;
411
+ svg += ` <rect x="0.5" y="0.5" width="${width - 1}" height="${headerH}" fill="${headerBg}" clip-path="url(#${clipId})"/>\n`;
412
+ svg += ` <line x1="0" y1="${headerH}" x2="${width}" y2="${headerH}" stroke="${borderColor}" stroke-width="1"/>\n`;
413
+
414
+ const h1 = this.headers[0] ?? '';
415
+ const h2 = this.headers[1] ?? '';
416
+ svg += ` <text x="16" y="${Math.round(headerH / 2 + 5)}" class="t-hdr">${escapeSvg(h1)}</text>\n`;
417
+ if (h2) {
418
+ svg += ` <text x="${col1Width + 16}" y="${Math.round(headerH / 2 + 5)}" class="t-hdr">${escapeSvg(h2)}</text>\n`;
419
+ }
420
+ }
421
+
422
+ // Vertical column divider
423
+ svg += ` <line x1="${col1Width}" y1="0" x2="${col1Width}" y2="${totalHeight}" stroke="${borderColor}" stroke-width="1"/>\n`;
424
+
425
+ // Data rows
426
+ for (let i = 0; i < this.rows.length; i++) {
427
+ const row = this.rows[i];
428
+ const y = headerH + (i * rowHeight);
429
+
430
+ if (i > 0 || hasHeader) {
431
+ svg += ` <line x1="0" y1="${y}" x2="${width}" y2="${y}" stroke="${borderColor}" stroke-width="1"/>\n`;
432
+ }
433
+
434
+ const textY = y + Math.round(rowHeight / 2 + 5);
435
+ const col1Val = row[0] !== undefined ? String(row[0]) : '';
436
+ const col2Val = row[1] !== undefined ? String(row[1]) : '';
437
+
438
+ svg += ` <text x="16" y="${textY}" class="t-lbl">${escapeSvg(col1Val)}</text>\n`;
439
+
440
+ if (col2Val) {
441
+ const isLink = col2Val.startsWith('@') || col2Val.startsWith('tg://') || col2Val.startsWith('http');
442
+ const isBold = options.boldValues === true || (options.boldValues !== false && (
443
+ ['telegraf.js', 'telegix', 'free user', 'premium', 'active', 'online', 'pro', 'connected', 'operational', 'success', 'ok'].includes(col2Val.toLowerCase()) ||
444
+ i === 0 ||
445
+ !isNaN(Number(col2Val))
446
+ ));
447
+ const cls = isLink ? 't-link' : (isBold ? 't-val-bold' : 't-val');
448
+ svg += ` <text x="${col1Width + 16}" y="${textY}" class="${cls}">${escapeSvg(col2Val)}</text>\n`;
449
+ }
450
+ }
451
+
452
+ svg += `</svg>`;
453
+ return svg;
454
+ }
455
+
456
+ /**
457
+ * Convert SVG table to Buffer (Node.js) or Uint8Array
458
+ * @param {object} [options]
459
+ * @returns {Buffer|Uint8Array}
460
+ */
461
+ toBuffer(options = {}) {
462
+ const svg = this.toCardSvg(options);
463
+ if (typeof Buffer !== 'undefined') {
464
+ return Buffer.from(svg, 'utf-8');
465
+ }
466
+ return new TextEncoder().encode(svg);
467
+ }
468
+
469
+ /**
470
+ * Convert SVG table to base64 Data URL
471
+ * @param {object} [options]
472
+ * @returns {string}
473
+ */
474
+ toDataUrl(options = {}) {
475
+ const svg = this.toCardSvg(options);
476
+ if (typeof Buffer !== 'undefined') {
477
+ return `data:image/svg+xml;base64,${Buffer.from(svg, 'utf-8').toString('base64')}`;
478
+ }
479
+ return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
480
+ }
481
+
482
+ /**
483
+ * Convert to Telegram Bot API 10.3 InputRichBlockTable / RichBlockTable payload
484
+ * @returns {object}
485
+ */
486
+ toRichBlock() {
487
+ return {
488
+ type: 'table',
489
+ is_bordered: this.is_bordered,
490
+ is_compact: this.is_compact,
491
+ is_striped: this.is_striped,
492
+ cells: this.toCells(),
493
+ ...(this.caption ? { caption: this.caption } : {}),
494
+ ...(this.headers.length > 0 ? { headers: this.headers } : {}),
495
+ rows: this.rows,
496
+ ...(this._title ? { title: this._title } : {}),
497
+ };
498
+ }
499
+
500
+ /**
501
+ * JSON serialization for Bot API 10.3
502
+ */
503
+ toJSON() {
504
+ return this.toRichBlock();
505
+ }
506
+
507
+ // ==========================================
508
+ // Static Factory & Formatting Methods
509
+ // ==========================================
510
+
511
+ /**
512
+ * Factory method to create a new Table instance
513
+ * @param {object} [options]
514
+ * @returns {Table}
515
+ */
516
+ static create(options) {
517
+ return new Table(options);
518
+ }
519
+
520
+ /**
521
+ * Create Table from array of JavaScript objects
522
+ * @param {Array<object>} array
523
+ * @param {Array<string>} [columns] - Optional specific columns or keys
524
+ * @param {object} [options]
525
+ * @returns {Table}
526
+ */
527
+ static fromObjects(array, columns, options = {}) {
528
+ if (!Array.isArray(array) || array.length === 0) {
529
+ return new Table(options);
530
+ }
531
+
532
+ const cols = columns && columns.length > 0
533
+ ? columns
534
+ : Object.keys(array[0]);
535
+
536
+ const table = new Table({
537
+ headers: cols.map((c) => c.charAt(0).toUpperCase() + c.slice(1).replace(/_/g, ' ')),
538
+ ...options,
539
+ });
540
+
541
+ for (const item of array) {
542
+ const row = cols.map((c) => item[c]);
543
+ table.row(row);
544
+ }
545
+
546
+ return table;
547
+ }
548
+
549
+ /**
550
+ * Format headers and rows into a formatted string
551
+ * @param {Array<string>} headers
552
+ * @param {Array<Array<any>>} rows
553
+ * @param {object} [options]
554
+ * @param {string} [options.style='box'] - 'box', 'ascii', 'compact', 'clean', 'markdown'
555
+ * @param {boolean} [options.isCompact=false]
556
+ * @param {Array<'left'|'center'|'right'>} [options.alignments]
557
+ * @returns {string}
558
+ */
559
+ static format(headers = [], rows = [], options = {}) {
560
+ const opts = typeof options === 'string' ? { style: options } : (options || {});
561
+ const styleName = opts.style || (opts.isCompact || opts.is_compact ? 'compact' : 'box');
562
+
563
+ if (styleName === 'markdown') {
564
+ return Table.markdown(headers, rows, opts);
565
+ }
566
+
567
+ const border = STYLES[styleName] || STYLES.box;
568
+ const alignments = opts.alignments || [];
569
+ const isCompact = Boolean(opts.isCompact || opts.is_compact);
570
+
571
+ const numCols = Math.max(
572
+ headers.length,
573
+ ...rows.map((r) => (Array.isArray(r) ? r.length : 0)),
574
+ 1
575
+ );
576
+
577
+ // Calculate maximum width for each column
578
+ const colWidths = new Array(numCols).fill(0);
579
+
580
+ for (let c = 0; c < numCols; c++) {
581
+ if (headers[c] !== undefined) {
582
+ colWidths[c] = Math.max(colWidths[c], visualLength(headers[c]));
583
+ }
584
+ for (const row of rows) {
585
+ if (row && row[c] !== undefined) {
586
+ colWidths[c] = Math.max(colWidths[c], visualLength(row[c]));
587
+ }
588
+ }
589
+ // Minimum column width of 1 character
590
+ colWidths[c] = Math.max(colWidths[c], 1);
591
+ }
592
+
593
+ const padding = isCompact ? 0 : 1;
594
+ const padChar = ' ';
595
+ const lines = [];
596
+
597
+ // Helper to format a cell with padding
598
+ const formatCell = (val, colIdx) => {
599
+ const w = colWidths[colIdx];
600
+ const align = alignments[colIdx] || 'left';
601
+ const text = pad(val, w, align);
602
+ return isCompact ? text : `${padChar}${text}${padChar}`;
603
+ };
604
+
605
+ // Helper to build a horizontal border row
606
+ const buildBorder = (left, mid, right, horiz) => {
607
+ if (!left && !mid && !right) return '';
608
+ const parts = colWidths.map((w) => horiz.repeat(w + (isCompact ? 0 : 2)));
609
+ return `${left}${parts.join(mid)}${right}`;
610
+ };
611
+
612
+ // Top border
613
+ if (border.topLeft || border.topMid || border.topRight) {
614
+ const topRow = buildBorder(border.topLeft, border.topMid, border.topRight, border.horizontal);
615
+ if (topRow) lines.push(topRow);
616
+ }
617
+
618
+ // Headers row
619
+ if (headers.length > 0) {
620
+ const headerCells = [];
621
+ for (let c = 0; c < numCols; c++) {
622
+ headerCells.push(formatCell(headers[c] ?? '', c));
623
+ }
624
+ lines.push(`${border.vertical}${headerCells.join(border.vertical)}${border.vertical}`);
625
+
626
+ // Mid separator row
627
+ if (border.midLeft || border.midMid || border.midRight || border.horizontal) {
628
+ const midRow = buildBorder(border.midLeft, border.midMid, border.midRight, border.horizontal);
629
+ if (midRow) lines.push(midRow);
630
+ }
631
+ }
632
+
633
+ // Data rows
634
+ for (let r = 0; r < rows.length; r++) {
635
+ const row = rows[r] || [];
636
+ const cells = [];
637
+ for (let c = 0; c < numCols; c++) {
638
+ cells.push(formatCell(row[c] ?? '', c));
639
+ }
640
+ lines.push(`${border.vertical}${cells.join(border.vertical)}${border.vertical}`);
641
+ }
642
+
643
+ // Bottom border
644
+ if (border.bottomLeft || border.bottomMid || border.bottomRight) {
645
+ const bottomRow = buildBorder(border.bottomLeft, border.bottomMid, border.bottomRight, border.horizontal);
646
+ if (bottomRow) lines.push(bottomRow);
647
+ }
648
+
649
+ return lines.join('\n');
650
+ }
651
+
652
+ /**
653
+ * Shortcut for box Unicode table
654
+ * @param {Array<string>} headers
655
+ * @param {Array<Array<any>>} rows
656
+ * @param {object} [options]
657
+ */
658
+ static box(headers, rows, options = {}) {
659
+ return Table.format(headers, rows, { ...options, style: 'box' });
660
+ }
661
+
662
+ /**
663
+ * Shortcut for ASCII table (+----+----+ etc.)
664
+ * @param {Array<string>} headers
665
+ * @param {Array<Array<any>>} rows
666
+ * @param {object} [options]
667
+ */
668
+ static ascii(headers, rows, options = {}) {
669
+ return Table.format(headers, rows, { ...options, style: 'ascii' });
670
+ }
671
+
672
+ /**
673
+ * Shortcut for compact table
674
+ * @param {Array<string>} headers
675
+ * @param {Array<Array<any>>} rows
676
+ * @param {object} [options]
677
+ */
678
+ static compact(headers, rows, options = {}) {
679
+ return Table.format(headers, rows, { ...options, style: 'compact', isCompact: true });
680
+ }
681
+
682
+ /**
683
+ * Generate Markdown table format (| Col 1 | Col 2 |)
684
+ * @param {Array<string>} headers
685
+ * @param {Array<Array<any>>} rows
686
+ * @param {object} [options]
687
+ */
688
+ static markdown(headers = [], rows = [], options = {}) {
689
+ const alignments = options.alignments || [];
690
+ const numCols = Math.max(
691
+ headers.length,
692
+ ...rows.map((r) => (Array.isArray(r) ? r.length : 0)),
693
+ 1
694
+ );
695
+
696
+ const colWidths = new Array(numCols).fill(3);
697
+ for (let c = 0; c < numCols; c++) {
698
+ if (headers[c] !== undefined) {
699
+ colWidths[c] = Math.max(colWidths[c], visualLength(headers[c]));
700
+ }
701
+ for (const row of rows) {
702
+ if (row && row[c] !== undefined) {
703
+ colWidths[c] = Math.max(colWidths[c], visualLength(row[c]));
704
+ }
705
+ }
706
+ }
707
+
708
+ const lines = [];
709
+
710
+ // Header
711
+ const headerCells = [];
712
+ for (let c = 0; c < numCols; c++) {
713
+ headerCells.push(pad(headers[c] ?? '', colWidths[c], 'left'));
714
+ }
715
+ lines.push(`| ${headerCells.join(' | ')} |`);
716
+
717
+ // Separator with alignment indicators
718
+ const sepCells = [];
719
+ for (let c = 0; c < numCols; c++) {
720
+ const align = alignments[c] || 'left';
721
+ const w = colWidths[c];
722
+ if (align === 'center') {
723
+ sepCells.push(`:${'-'.repeat(Math.max(w - 2, 1))}:`);
724
+ } else if (align === 'right') {
725
+ sepCells.push(`${'-'.repeat(Math.max(w - 1, 1))}:`);
726
+ } else {
727
+ sepCells.push(`:${'-'.repeat(Math.max(w - 1, 1))}`);
728
+ }
729
+ }
730
+ lines.push(`| ${sepCells.join(' | ')} |`);
731
+
732
+ // Rows
733
+ for (const row of rows) {
734
+ const cells = [];
735
+ for (let c = 0; c < numCols; c++) {
736
+ const align = alignments[c] || 'left';
737
+ cells.push(pad(row[c] ?? '', colWidths[c], align));
738
+ }
739
+ lines.push(`| ${cells.join(' | ')} |`);
740
+ }
741
+
742
+ return lines.join('\n');
743
+ }
744
+
745
+ /**
746
+ * Helper to format table directly into HTML with <pre> tag for Telegram
747
+ * @param {Array<string>} headers
748
+ * @param {Array<Array<any>>} rows
749
+ * @param {object} [options]
750
+ */
751
+ static html(headers, rows, options = {}) {
752
+ const formatted = Table.format(headers, rows, options);
753
+ const title = options.title ? `<b>${escapeHtml(options.title)}</b>\n\n` : '';
754
+ return `${title}<pre>${escapeHtml(formatted)}</pre>`;
755
+ }
756
+
757
+ /**
758
+ * Create a Card Table matching modern Telegram Bot Card Table UI
759
+ * @param {Array<string>} headers
760
+ * @param {Array<Array<any>>} rows
761
+ * @param {object} [options]
762
+ * @returns {Table}
763
+ */
764
+ static card(headers, rows = [], options = {}) {
765
+ return new Table(headers, rows, {
766
+ style: 'card',
767
+ is_bordered: true,
768
+ ...options,
769
+ });
770
+ }
771
+
772
+ /**
773
+ * Create a pre-configured System Status Card Table (matching Telegram bot screenshot)
774
+ * @param {object} [data]
775
+ * @param {object} [options]
776
+ * @returns {Table}
777
+ */
778
+ static systemStatus(data = {}, options = {}) {
779
+ const defaultData = {
780
+ engine: 'Telegix',
781
+ runtime: '0h 19m 32s',
782
+ node: typeof process !== 'undefined' && process.version ? process.version : 'v23.11',
783
+ features: 514,
784
+ groups: 168,
785
+ users: 9528,
786
+ ...data,
787
+ };
788
+
789
+ return new Table(
790
+ ['🤖 SYSTEM', 'Status'],
791
+ [
792
+ ['Engine', defaultData.engine],
793
+ ['Runtime', defaultData.runtime],
794
+ ['Node', defaultData.node],
795
+ ['Features', defaultData.features],
796
+ ['Groups', defaultData.groups],
797
+ ['Users', defaultData.users],
798
+ ],
799
+ {
800
+ style: 'card',
801
+ is_bordered: true,
802
+ title: options.title || '',
803
+ ...options,
804
+ }
805
+ );
806
+ }
807
+
808
+ /**
809
+ * Create a pre-configured User Profile Card Table (matching Telegram bot screenshot)
810
+ * @param {object} [data]
811
+ * @param {object} [options]
812
+ * @returns {Table}
813
+ */
814
+ static userProfile(data = {}, options = {}) {
815
+ const defaultData = {
816
+ username: '@seventynn',
817
+ status: 'Free User',
818
+ limit: 0,
819
+ points: 0,
820
+ time: 'Selasa, 8 September 2026',
821
+ ...data,
822
+ };
823
+
824
+ return new Table(
825
+ ['👤 PROFILE', 'Info'],
826
+ [
827
+ ['Username', defaultData.username],
828
+ ['Status', defaultData.status],
829
+ ['Limit', defaultData.limit],
830
+ ['Points', defaultData.points],
831
+ ['Time', defaultData.time],
832
+ ],
833
+ {
834
+ style: 'card',
835
+ is_bordered: true,
836
+ title: options.title || '',
837
+ ...options,
838
+ }
839
+ );
840
+ }
841
+
842
+ /**
843
+ * Render multiple stacked card tables into a single SVG graphic
844
+ * (e.g. Card 1 SYSTEM and Card 2 PROFILE like in the Telegram bot screenshot)
845
+ * @param {Array<Table|object>} tables
846
+ * @param {object} [options]
847
+ * @returns {string} SVG XML markup string
848
+ */
849
+ static multiCardSvg(tables, options = {}) {
850
+ const list = Array.isArray(tables) ? tables : [tables];
851
+ const width = Number(options.width || 420);
852
+ const gap = Number(options.gap || 14);
853
+ const padding = Number(options.padding || 0);
854
+
855
+ const instances = list.map((t) => {
856
+ if (t instanceof Table) return t;
857
+ if (t && typeof t.toCardSvg === 'function') return t;
858
+ if (t && Array.isArray(t.headers) && Array.isArray(t.rows)) return new Table(t);
859
+ if (Array.isArray(t)) return new Table(t[0], t[1]);
860
+ return new Table(t);
861
+ });
862
+
863
+ let currentY = padding;
864
+ const renderedParts = [];
865
+
866
+ for (let idx = 0; idx < instances.length; idx++) {
867
+ const t = instances[idx];
868
+ const headerH = t.headers.length > 0 ? (options.headerHeight || 38) : 0;
869
+ const tHeight = headerH + (t.rows.length * (options.rowHeight || 36));
870
+ const col1Width = Number(options.col1Width || t.col1Width || Math.round(width * 0.36));
871
+ const headerHeight = Number(options.headerHeight || 38);
872
+ const rowHeight = Number(options.rowHeight || 36);
873
+ const hasHeader = t.headers.length > 0;
874
+
875
+ const bgColor = options.bgColor || '#18222d';
876
+ const borderColor = options.borderColor || '#2b3d4f';
877
+ const headerBg = options.headerBg || '#1c2836';
878
+
879
+ let g = ` <g transform="translate(${padding}, ${currentY})">\n`;
880
+ g += ` <rect x="0.5" y="0.5" width="${width - 1}" height="${tHeight - 1}" rx="12" ry="12" fill="${bgColor}" stroke="${borderColor}" stroke-width="1"/>\n`;
881
+
882
+ if (hasHeader) {
883
+ const clipId = `multi-clip-${idx}-${currentY}`;
884
+ g += ` <clipPath id="${clipId}">\n`;
885
+ g += ` <rect x="0.5" y="0.5" width="${width - 1}" height="${tHeight - 1}" rx="12" ry="12" />\n`;
886
+ g += ` </clipPath>\n`;
887
+ g += ` <rect x="0.5" y="0.5" width="${width - 1}" height="${headerH}" fill="${headerBg}" clip-path="url(#${clipId})"/>\n`;
888
+ g += ` <line x1="0" y1="${headerH}" x2="${width}" y2="${headerH}" stroke="${borderColor}" stroke-width="1"/>\n`;
889
+
890
+ const h1 = t.headers[0] ?? '';
891
+ const h2 = t.headers[1] ?? '';
892
+ g += ` <text x="16" y="${Math.round(headerH / 2 + 5)}" class="t-hdr">${escapeSvg(h1)}</text>\n`;
893
+ if (h2) {
894
+ g += ` <text x="${col1Width + 16}" y="${Math.round(headerH / 2 + 5)}" class="t-hdr">${escapeSvg(h2)}</text>\n`;
895
+ }
896
+ }
897
+
898
+ g += ` <line x1="${col1Width}" y1="0" x2="${col1Width}" y2="${tHeight}" stroke="${borderColor}" stroke-width="1"/>\n`;
899
+
900
+ for (let i = 0; i < t.rows.length; i++) {
901
+ const row = t.rows[i];
902
+ const y = headerH + (i * rowHeight);
903
+
904
+ if (i > 0 || hasHeader) {
905
+ g += ` <line x1="0" y1="${y}" x2="${width}" y2="${y}" stroke="${borderColor}" stroke-width="1"/>\n`;
906
+ }
907
+
908
+ const textY = y + Math.round(rowHeight / 2 + 5);
909
+ const col1Val = row[0] !== undefined ? String(row[0]) : '';
910
+ const col2Val = row[1] !== undefined ? String(row[1]) : '';
911
+
912
+ g += ` <text x="16" y="${textY}" class="t-lbl">${escapeSvg(col1Val)}</text>\n`;
913
+
914
+ if (col2Val) {
915
+ const isLink = col2Val.startsWith('@') || col2Val.startsWith('tg://') || col2Val.startsWith('http');
916
+ const isBold = options.boldValues !== false && (
917
+ ['Telegraf.js', 'Telegix', 'Free User', 'Premium', 'Active', 'Online', 'PRO'].includes(col2Val) ||
918
+ i === 0
919
+ );
920
+ const cls = isLink ? 't-link' : (isBold ? 't-val-bold' : 't-val');
921
+ g += ` <text x="${col1Width + 16}" y="${textY}" class="${cls}">${escapeSvg(col2Val)}</text>\n`;
922
+ }
923
+ }
924
+ g += ` </g>\n`;
925
+
926
+ renderedParts.push(g);
927
+ currentY += tHeight + gap;
928
+ }
929
+
930
+ const totalWidth = width + (padding * 2);
931
+ const totalHeight = currentY - gap + padding;
932
+ const fontFamily = options.fontFamily || '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif';
933
+ const labelColor = options.labelColor || '#90a4b7';
934
+ const valColor = options.valueColor || '#ffffff';
935
+ const linkColor = options.linkColor || '#5288c1';
936
+
937
+ let svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="${totalHeight}" viewBox="0 0 ${totalWidth} ${totalHeight}">\n`;
938
+ svg += ` <style>\n`;
939
+ svg += ` .t-lbl { font-family: ${fontFamily}; font-size: 14px; fill: ${labelColor}; font-weight: 400; }\n`;
940
+ svg += ` .t-val { font-family: ${fontFamily}; font-size: 14px; fill: ${valColor}; font-weight: 400; }\n`;
941
+ svg += ` .t-val-bold { font-family: ${fontFamily}; font-size: 14px; fill: ${valColor}; font-weight: 600; }\n`;
942
+ svg += ` .t-link { font-family: ${fontFamily}; font-size: 14px; fill: ${linkColor}; font-weight: 500; cursor: pointer; }\n`;
943
+ svg += ` .t-hdr { font-family: ${fontFamily}; font-size: 14px; fill: #ffffff; font-weight: 700; }\n`;
944
+ svg += ` </style>\n`;
945
+
946
+ svg += renderedParts.join('\n');
947
+ svg += `</svg>`;
948
+ return svg;
949
+ }
950
+ }
951
+
952
+ /**
953
+ * Telegram Bot API 10.3 InputRichBlockTable class
954
+ * Represents a structured table block in rich messages
955
+ */
956
+ export class InputRichBlockTable {
957
+ /**
958
+ * @param {Array<string>|object} [headersOrOptions]
959
+ * @param {Array<Array<any>>} [rows]
960
+ * @param {object} [options]
961
+ */
962
+ constructor(headersOrOptions = [], rows = [], options = {}) {
963
+ this.type = 'table';
964
+ if (headersOrOptions && !Array.isArray(headersOrOptions) && typeof headersOrOptions === 'object') {
965
+ const opt = headersOrOptions;
966
+ this.headers = opt.headers || [];
967
+ this.rows = opt.rows || [];
968
+ this.is_compact = Boolean(opt.is_compact ?? opt.isCompact ?? false);
969
+ this.is_bordered = Boolean(opt.is_bordered ?? opt.isBordered ?? true);
970
+ this.is_striped = Boolean(opt.is_striped ?? opt.isStriped ?? false);
971
+ this.caption = opt.caption || '';
972
+ this.alignments = opt.alignments || [];
973
+ this.title = opt.title || '';
974
+ this.style = opt.style || (this.is_compact ? 'compact' : 'box');
975
+ this.col1Width = opt.col1Width;
976
+ } else {
977
+ this.headers = Array.isArray(headersOrOptions) ? [...headersOrOptions] : [];
978
+ this.rows = Array.isArray(rows) ? rows.map((r) => [...r]) : [];
979
+ this.is_compact = Boolean(options.is_compact ?? options.isCompact ?? false);
980
+ this.is_bordered = Boolean(options.is_bordered ?? options.isBordered ?? true);
981
+ this.is_striped = Boolean(options.is_striped ?? options.isStriped ?? false);
982
+ this.caption = options.caption || '';
983
+ this.alignments = options.alignments || [];
984
+ this.title = options.title || '';
985
+ this.style = options.style || (this.is_compact ? 'compact' : 'box');
986
+ this.col1Width = options.col1Width;
987
+ }
988
+ }
989
+
990
+ /**
991
+ * Set compact mode
992
+ * @param {boolean} [isCompact=true]
993
+ * @returns {this}
994
+ */
995
+ compact(isCompact = true) {
996
+ this.is_compact = Boolean(isCompact);
997
+ return this;
998
+ }
999
+
1000
+ /**
1001
+ * Set bordered mode
1002
+ * @param {boolean} [isBordered=true]
1003
+ * @returns {this}
1004
+ */
1005
+ bordered(isBordered = true) {
1006
+ this.is_bordered = Boolean(isBordered);
1007
+ return this;
1008
+ }
1009
+
1010
+ /**
1011
+ * Set striped mode
1012
+ * @param {boolean} [isStriped=true]
1013
+ * @returns {this}
1014
+ */
1015
+ striped(isStriped = true) {
1016
+ this.is_striped = Boolean(isStriped);
1017
+ return this;
1018
+ }
1019
+
1020
+ /**
1021
+ * Add a row of cells
1022
+ * @param {...any} cells
1023
+ * @returns {this}
1024
+ */
1025
+ addRow(...cells) {
1026
+ if (cells.length === 1 && Array.isArray(cells[0])) {
1027
+ this.rows.push([...cells[0]]);
1028
+ } else {
1029
+ this.rows.push(cells.flat());
1030
+ }
1031
+ return this;
1032
+ }
1033
+
1034
+ /**
1035
+ * Convert table data into 2D array of RichBlockTableCell for Bot API 10.3
1036
+ */
1037
+ toCells() {
1038
+ const cells = [];
1039
+ if (this.headers.length > 0) {
1040
+ cells.push(
1041
+ this.headers.map((h, i) => ({
1042
+ text: String(h ?? ''),
1043
+ is_header: true,
1044
+ align: this.alignments[i] || 'center',
1045
+ valign: 'middle',
1046
+ }))
1047
+ );
1048
+ }
1049
+ for (const row of this.rows) {
1050
+ cells.push(
1051
+ row.map((cell, i) => {
1052
+ if (cell && typeof cell === 'object' && cell.text !== undefined) {
1053
+ return {
1054
+ align: this.alignments[i] || 'left',
1055
+ valign: 'middle',
1056
+ ...cell,
1057
+ };
1058
+ }
1059
+ return {
1060
+ text: String(cell ?? ''),
1061
+ align: this.alignments[i] || 'left',
1062
+ valign: 'middle',
1063
+ };
1064
+ })
1065
+ );
1066
+ }
1067
+ return cells;
1068
+ }
1069
+
1070
+ /**
1071
+ * Render table as SVG Card
1072
+ * @param {object} [options]
1073
+ * @returns {string}
1074
+ */
1075
+ toCardSvg(options = {}) {
1076
+ const t = new Table({
1077
+ headers: this.headers,
1078
+ rows: this.rows,
1079
+ col1Width: this.col1Width,
1080
+ ...options,
1081
+ });
1082
+ return t.toCardSvg(options);
1083
+ }
1084
+
1085
+ /**
1086
+ * Render HTML representation
1087
+ */
1088
+ toHtml(options = {}) {
1089
+ const tableStr = Table.format(this.headers, this.rows, {
1090
+ style: options.style || this.style,
1091
+ isCompact: this.is_compact,
1092
+ alignments: this.alignments,
1093
+ ...options,
1094
+ });
1095
+ const titleHtml = this.title ? `<b>${escapeHtml(this.title)}</b>\n\n` : '';
1096
+ return `${titleHtml}<pre>${escapeHtml(tableStr)}</pre>`;
1097
+ }
1098
+
1099
+ /**
1100
+ * Convert to Bot API 10.3 JSON payload
1101
+ */
1102
+ toJSON() {
1103
+ return {
1104
+ type: 'table',
1105
+ is_bordered: this.is_bordered,
1106
+ is_compact: this.is_compact,
1107
+ is_striped: this.is_striped,
1108
+ cells: this.toCells(),
1109
+ ...(this.caption ? { caption: this.caption } : {}),
1110
+ ...(this.headers.length > 0 ? { headers: this.headers } : {}),
1111
+ rows: this.rows,
1112
+ ...(this.title ? { title: this.title } : {}),
1113
+ };
1114
+ }
1115
+
1116
+ static create(headers, rows, options) {
1117
+ return new InputRichBlockTable(headers, rows, options);
1118
+ }
1119
+ }
1120
+
1121
+ /**
1122
+ * Alias for Telegram Bot API 10.3 RichBlockTable
1123
+ */
1124
+ export const RichBlockTable = InputRichBlockTable;