1: <?php
2: /**
3: * This file is part of the PHPLucidFrame library.
4: * The class makes you easy to build console style tables
5: *
6: * @package PHPLucidFrame\Console
7: * @since PHPLucidFrame v 1.12.0
8: * @copyright Copyright (c), PHPLucidFrame.
9: * @author Sithu <sithu@phplucidframe.com>
10: * @link http://phplucidframe.com
11: * @license http://www.opensource.org/licenses/mit-license.php MIT License
12: *
13: * This source file is subject to the MIT license that is bundled
14: * with this source code in the file LICENSE
15: */
16:
17: namespace LucidFrame\Console;
18:
19: /**
20: * The class makes you easy to build console style tables
21: */
22: class ConsoleTable
23: {
24: const HEADER_INDEX = -1;
25: const FOOTER_INDEX = -2;
26: const HR = 'HR';
27:
28: const ALIGN_LEFT = 'left';
29: const ALIGN_RIGHT = 'right';
30:
31: /** @var array Array of table data */
32: protected $data = array();
33: /** @var boolean Border shown or not */
34: protected $border = true;
35: /** @var boolean All borders shown or not */
36: protected $allBorders = false;
37: /** @var integer Table padding */
38: protected $padding = 1;
39: /** @var integer Table left margin */
40: protected $indent = 0;
41: /** @var integer */
42: private $rowIndex = -1;
43: /** @var array */
44: private $columnWidths = array();
45: /** @var int */
46: private $maxColumnCount = 0;
47: /** @var array */
48: private $headerColumnAligns = [];
49: /** @var array */
50: private $columnAligns = [];
51: /** @var array */
52: private $footerColumnAligns = [];
53:
54: /**
55: * Add a column to the table header
56: * @param mixed $content Header cell content
57: * @param string $align The text alignment ('left' or 'right')
58: * @return object LucidFrame\Console\ConsoleTable
59: */
60: public function addHeader($content = '', $align = self::ALIGN_LEFT)
61: {
62: $this->data[self::HEADER_INDEX][] = $content;
63: $this->headerColumnAligns[self::HEADER_INDEX][] = $align === self::ALIGN_RIGHT ? STR_PAD_LEFT : STR_PAD_RIGHT;
64:
65: return $this;
66: }
67:
68: /**
69: * Set headers for the columns in one-line
70: * @param array $content Array of header cell content
71: * @return object LucidFrame\Console\ConsoleTable
72: */
73: public function setHeaders(array $content)
74: {
75: $this->data[self::HEADER_INDEX] = $content;
76:
77: return $this;
78: }
79:
80: /**
81: * Get the row of header
82: */
83: public function getHeaders()
84: {
85: return $this->data[self::HEADER_INDEX] ?? null;
86: }
87:
88: /**
89: * Adds a row to the table
90: * @param array|null $data The row data to add
91: * @return object LucidFrame\Console\ConsoleTable
92: */
93: public function addRow(?array $data = null)
94: {
95: $this->rowIndex++;
96:
97: if (is_array($data)) {
98: foreach ($data as $col => $content) {
99: $this->data[$this->rowIndex][$col] = $content;
100: }
101:
102: $this->setMaxColumnCount(count($this->data[$this->rowIndex]));
103: }
104:
105: return $this;
106: }
107:
108: /**
109: * Adds a column to the table
110: * @param mixed $content The data of the column
111: * @param integer $col The column index to populate
112: * @param integer $row If starting row is not zero, specify it here
113: * @param string $align The text alignment ('left' or 'right')
114: * @return object LucidFrame\Console\ConsoleTable
115: */
116: public function addColumn($content, $col = null, $row = null, $align = self::ALIGN_LEFT)
117: {
118: $row = $row ?? $this->rowIndex;
119: if ($col === null) {
120: $col = isset($this->data[$row]) ? count($this->data[$row]) : 0;
121: }
122:
123: $this->data[$row][$col] = $content;
124: $this->setMaxColumnCount(count($this->data[$row]));
125:
126: // Set column alignment if specified
127: if (!isset($this->columnAligns[$col]) && $align === self::ALIGN_RIGHT) {
128: $this->columnAligns[$col] = STR_PAD_LEFT;
129: }
130:
131: return $this;
132: }
133:
134: /**
135: * Set alignment for a specific column
136: * @param integer $col The column index
137: * @param string $align The alignment ('left' or 'right')
138: * @return object LucidFrame\Console\ConsoleTable
139: */
140: public function setColumnAlign($col, $align = self::ALIGN_LEFT)
141: {
142: $this->columnAligns[$col] = $align === self::ALIGN_RIGHT ? STR_PAD_LEFT : STR_PAD_RIGHT;
143:
144: return $this;
145: }
146:
147: /**
148: * Show table border
149: * @return object LucidFrame\Console\ConsoleTable
150: */
151: public function showBorder()
152: {
153: $this->border = true;
154:
155: return $this;
156: }
157:
158: /**
159: * Hide table border
160: * @return object LucidFrame\Console\ConsoleTable
161: */
162: public function hideBorder()
163: {
164: $this->border = false;
165:
166: return $this;
167: }
168:
169: /**
170: * Show all table borders
171: * @return object LucidFrame\Console\ConsoleTable
172: */
173: public function showAllBorders()
174: {
175: $this->showBorder();
176: $this->allBorders = true;
177:
178: return $this;
179: }
180:
181: /**
182: * Set padding for each cell
183: * @param integer $value The integer value, defaults to 1
184: * @return object LucidFrame\Console\ConsoleTable
185: */
186: public function setPadding($value = 1)
187: {
188: $this->padding = $value;
189:
190: return $this;
191: }
192:
193: /**
194: * Set left indentation for the table
195: * @param integer $value The integer value, defaults to 1
196: * @return object LucidFrame\Console\ConsoleTable
197: */
198: public function setIndent($value = 0)
199: {
200: $this->indent = $value;
201:
202: return $this;
203: }
204:
205: /**
206: * Add horizontal borderline
207: * @return object LucidFrame\Console\ConsoleTable
208: */
209: public function addBorderLine()
210: {
211: $this->rowIndex++;
212: $this->data[$this->rowIndex] = self::HR;
213:
214: return $this;
215: }
216:
217: /**
218: * Add a column to the table footer
219: * @param mixed $content Footer cell content
220: * @param string $align The text alignment ('left' or 'right')
221: * @return object LucidFrame\Console\ConsoleTable
222: */
223: public function addFooter($content = '', $align = self::ALIGN_LEFT)
224: {
225: $this->data[self::FOOTER_INDEX][] = $content;
226: $this->footerColumnAligns[self::FOOTER_INDEX][] = $align === self::ALIGN_RIGHT ? STR_PAD_LEFT : STR_PAD_RIGHT;
227:
228: return $this;
229: }
230:
231: /**
232: * Set footers for the columns in one-line
233: * @param array $content Array of footer cell content
234: * @return object LucidFrame\Console\ConsoleTable
235: */
236: public function setFooters(array $content)
237: {
238: $this->data[self::FOOTER_INDEX] = $content;
239:
240: return $this;
241: }
242:
243: /**
244: * Get the row of footer
245: */
246: public function getFooters()
247: {
248: return $this->data[self::FOOTER_INDEX] ?? null;
249: }
250:
251: /**
252: * Print the table
253: * @return void
254: */
255: public function display()
256: {
257: echo $this->getTable();
258: }
259:
260: /**
261: * Get the printable table content
262: * @return string
263: */
264: public function getTable()
265: {
266: $this->calculateColumnWidth();
267:
268: $output = $this->border ? $this->getBorderLine() : '';
269: foreach ($this->data as $y => $row) {
270: if ($y === self::FOOTER_INDEX) {
271: continue; // Skip footer here, we'll add it at the end
272: }
273:
274: if ($row === self::HR) {
275: if (!$this->allBorders) {
276: $output .= $this->getBorderLine();
277: unset($this->data[$y]);
278: }
279:
280: continue;
281: }
282:
283: if ($y === self::HEADER_INDEX && count($row) < $this->maxColumnCount) {
284: $row += array_fill(count($row), $this->maxColumnCount - count($row), ' ');
285: }
286:
287: foreach ($row as $x => $cell) {
288: $output .= $this->getCellOutput($x, $y, $row);
289: }
290: $output .= PHP_EOL;
291:
292: if ($y === self::HEADER_INDEX) {
293: $output .= $this->getBorderLine();
294: } else if ($this->allBorders) {
295: $output .= $this->getBorderLine();
296: }
297: }
298:
299: // Add footer if exists
300: if (isset($this->data[self::FOOTER_INDEX])) {
301: if (!$this->allBorders) {
302: $output .= $this->getBorderLine();
303: }
304:
305: $row = $this->data[self::FOOTER_INDEX];
306: if (count($row) < $this->maxColumnCount) {
307: $row += array_fill(count($row), $this->maxColumnCount - count($row), ' ');
308: }
309:
310: foreach ($row as $x => $cell) {
311: $output .= $this->getCellOutput($x, self::FOOTER_INDEX, $row);
312: }
313: $output .= PHP_EOL;
314: }
315:
316: if (!$this->allBorders) {
317: $output .= $this->border ? $this->getBorderLine() : '';
318: }
319:
320: if (PHP_SAPI !== 'cli') {
321: $output = '<pre>'.$output.'</pre>';
322: }
323:
324: return $output;
325: }
326:
327: /**
328: * Get the printable borderline
329: * @return string
330: */
331: private function getBorderLine()
332: {
333: $output = '';
334:
335: if (isset($this->data[0])) {
336: $columnCount = count($this->data[0]);
337: } elseif (isset($this->data[self::HEADER_INDEX])) {
338: $columnCount = count($this->data[self::HEADER_INDEX]);
339: } else {
340: return $output;
341: }
342:
343: for ($col = 0; $col < $columnCount; $col++) {
344: $output .= $this->getCellOutput($col);
345: }
346:
347: if ($this->border) {
348: $output .= '+';
349: }
350: $output .= PHP_EOL;
351:
352: return $output;
353: }
354:
355: /**
356: * Get the printable cell content
357: *
358: * @param int $colIndex The column index
359: * @param int $rowIndex The row index
360: * @param array $row The table row
361: * @return string
362: */
363: private function getCellOutput($colIndex, $rowIndex = null, $row = [])
364: {
365: $cell = $row ? $row[$colIndex] : '-';
366: $padding = str_repeat($row ? ' ' : '-', $this->padding);
367: $output = '';
368:
369: if ($colIndex === 0) {
370: $output .= str_repeat(' ', $this->indent);
371: }
372:
373: if ($this->border) {
374: $output .= $row ? '|' : '+';
375: }
376:
377: $output .= $padding; # left padding
378:
379: // Apply column alignment
380: if ($rowIndex === self::HEADER_INDEX) {
381: $alignment = $this->headerColumnAligns[$rowIndex][$colIndex] ?? STR_PAD_RIGHT;
382: } elseif ($rowIndex === self::FOOTER_INDEX) {
383: $alignment = $this->footerColumnAligns[$rowIndex][$colIndex] ?? STR_PAD_RIGHT;
384: } else {
385: $alignment = $this->columnAligns[$colIndex] ?? STR_PAD_RIGHT;
386: }
387:
388: $output .= $this->strPadUnicode($cell, $this->getVisualWidth($colIndex, $cell), $row ? ' ' : '-', $alignment); # cell content
389: $output .= $padding; # right padding
390: if ($colIndex === count($row) - 1 && $this->border) {
391: $output .= $row ? '|' : '+';
392: }
393:
394: return $output;
395: }
396:
397: /**
398: * Get the visual width of the cell after the removal of invisible ANSI sequences.
399: *
400: * @param int $index The column index
401: * @param string $content The cell content
402: * @return int|mixed
403: */
404: private function getVisualWidth($index, $content)
405: {
406: $colWidth = $this->columnWidths[$index];
407: # removes line breaks, tabs, and extra spaces
408: $cell = trim(preg_replace('/\s+/', ' ', $content));
409: # removes ANSI escape sequences (commonly used for terminal text color and formatting)
410: $cleanContent = $this->clearTextFormatting($cell);
411:
412: $originalContentLen = mb_strlen($cell, 'UTF-8');
413: $cleanContentLen = mb_strlen($cleanContent, 'UTF-8');
414:
415: # calculate the number of characters removed (i.e., length of the ANSI sequences).
416: $delta = $originalContentLen - $cleanContentLen;
417:
418: return $colWidth + $delta - $this->countEmojis($cleanContent);
419: }
420:
421: /**
422: * Calculate maximum width of each column
423: * @return array
424: */
425: private function calculateColumnWidth()
426: {
427: $maxEmojiCount = [];
428:
429: foreach ($this->data as $row) {
430: if (!is_array($row)) {
431: continue;
432: }
433:
434: foreach ($row as $x => $cell) {
435: $content = $this->clearTextFormatting($cell);
436: $emojiCount = $this->countEmojis($content);
437: if (!isset($maxEmojiCount[$x])) {
438: $maxEmojiCount[$x] = $emojiCount;
439: } elseif ($emojiCount > $maxEmojiCount[$x]) {
440: $maxEmojiCount[$x] = $emojiCount;
441: }
442: }
443: }
444:
445: foreach ($this->data as $row) {
446: if (is_array($row)) {
447: foreach ($row as $x => $cell) {
448: $content = $this->clearTextFormatting($cell);
449: $textLength = mb_strlen($content, 'UTF-8');
450: $colWidth = $textLength + $maxEmojiCount[$x];
451:
452: if (!isset($this->columnWidths[$x])) {
453: $this->columnWidths[$x] = $colWidth;
454: } else {
455: if ($colWidth > $this->columnWidths[$x]) {
456: $this->columnWidths[$x] = $colWidth;
457: }
458: }
459: }
460: }
461: }
462:
463: return $this->columnWidths;
464: }
465:
466: /**
467: * Multibyte version of str_pad() function
468: * @source http://php.net/manual/en/function.str-pad.php
469: */
470: private function strPadUnicode($str, $padLength, $padString = ' ', $dir = STR_PAD_RIGHT)
471: {
472: $strLen = mb_strlen($str, 'UTF-8');
473: $padStrLen = mb_strlen($padString, 'UTF-8');
474:
475: if (!$strLen && ($dir == STR_PAD_RIGHT || $dir == STR_PAD_LEFT)) {
476: $strLen = 1;
477: }
478:
479: if (!$padLength || !$padStrLen || $padLength <= $strLen) {
480: return $str;
481: }
482:
483: $result = null;
484: $repeat = ceil($strLen - $padStrLen + $padLength);
485: if ($dir == STR_PAD_RIGHT) {
486: $result = $str . str_repeat($padString, $repeat);
487: $result = mb_substr($result, 0, $padLength, 'UTF-8');
488: } elseif ($dir == STR_PAD_LEFT) {
489: $result = str_repeat($padString, $repeat) . $str;
490: $result = mb_substr($result, -$padLength, null, 'UTF-8');
491: } elseif ($dir == STR_PAD_BOTH) {
492: $length = ($padLength - $strLen) / 2;
493: $repeat = ceil($length / $padStrLen);
494: $result = mb_substr(str_repeat($padString, $repeat), 0, floor($length), 'UTF-8')
495: . $str
496: . mb_substr(str_repeat($padString, $repeat), 0, ceil($length), 'UTF-8');
497: }
498:
499: return $result;
500: }
501:
502: /**
503: * Set max column count
504: * @param int $count The column count
505: */
506: private function setMaxColumnCount($count)
507: {
508: if ($count > $this->maxColumnCount) {
509: $this->maxColumnCount = $count;
510: }
511: }
512:
513: /**
514: * Remove ANSI escape codes (which are often used for terminal formatting) for plain text processing
515: * ANSI escape codes are often used to control text formatting in terminals (e.g., colors, boldness).
516: *
517: * @param string $content The cell content
518: * @return array|string|string[]|null
519: */
520: private function clearTextFormatting($content)
521: {
522: return preg_replace('#\x1b[[][^A-Za-z]*[A-Za-z]#', '', $content);
523: }
524:
525: /**
526: * Detect and count emojis from text
527: * @param string $text
528: * @return int
529: */
530: private function countEmojis($text)
531: {
532: $emojiPattern = '([*#0-9](?>\\xEF\\xB8\\x8F)?\\xE2\\x83\\xA3|\\xC2[\\xA9\\xAE]|\\xE2..(\\xF0\\x9F\\x8F[\\xBB-\\xBF])?(?>\\xEF\\xB8\\x8F)?|\\xE3(?>\\x80[\\xB0\\xBD]|\\x8A[\\x97\\x99])(?>\\xEF\\xB8\\x8F)?|\\xF0\\x9F(?>[\\x80-\\x86].(?>\\xEF\\xB8\\x8F)?|\\x87.\\xF0\\x9F\\x87.|..(\\xF0\\x9F\\x8F[\\xBB-\\xBF])?|(((?<zwj>\\xE2\\x80\\x8D)\\xE2\\x9D\\xA4\\xEF\\xB8\\x8F\k<zwj>\\xF0\\x9F..(\k<zwj>\\xF0\\x9F\\x91.)?|(\\xE2\\x80\\x8D\\xF0\\x9F\\x91.){2,3}))?))';
533:
534: preg_match_all($emojiPattern, $text, $matches);
535:
536: return count($matches[0]);
537: }
538: }
539: