1 <?php
2 
3 namespace splitbrain\phpcli;
4 
5 /**
6  * Class TableFormatter
7  *
8  * Output text in multiple columns
9  *
10  * @author Andreas Gohr <andi@splitbrain.org>
11  * @license MIT
12  */
13 class TableFormatter
14 {
15     /** @var string border between columns */
16     protected $border = ' ';
17 
18     /** @var int the terminal width */
19     protected $max = 74;
20 
21     /** @var Colors for coloring output */
22     protected $colors;
23 
24     /**
25      * TableFormatter constructor.
26      *
27      * @param Colors|null $colors
28      */
29     public function __construct(Colors $colors = null)
30     {
31         // try to get terminal width
32         $width = $this->getTerminalWidth();
33         if ($width) {
34             $this->max = $width - 1;
35         }
36 
37         if ($colors) {
38             $this->colors = $colors;
39         } else {
40             $this->colors = new Colors();
41         }
42     }
43 
44     /**
45      * The currently set border (defaults to ' ')
46      *
47      * @return string
48      */
49     public function getBorder()
50     {
51         return $this->border;
52     }
53 
54     /**
55      * Set the border. The border is set between each column. Its width is
56      * added to the column widths.
57      *
58      * @param string $border
59      */
60     public function setBorder($border)
61     {
62         $this->border = $border;
63     }
64 
65     /**
66      * Width of the terminal in characters
67      *
68      * initially autodetected
69      *
70      * @return int
71      */
72     public function getMaxWidth()
73     {
74         return $this->max;
75     }
76 
77     /**
78      * Set the width of the terminal to assume (in characters)
79      *
80      * @param int $max
81      */
82     public function setMaxWidth($max)
83     {
84         $this->max = $max;
85     }
86 
87     /**
88      * Tries to figure out the width of the terminal
89      *
90      * @return int terminal width, 0 if unknown
91      */
92     protected function getTerminalWidth()
93     {
94         // from environment
95         if (isset($_SERVER['COLUMNS'])) return (int)$_SERVER['COLUMNS'];
96 
97         // via tput
98         $process = proc_open('tput cols', array(
99             1 => array('pipe', 'w'),
100             2 => array('pipe', 'w'),
101         ), $pipes);
102         $width = (int)stream_get_contents($pipes[1]);
103         proc_close($process);
104 
105         return $width;
106     }
107 
108     /**
109      * Takes an array with dynamic column width and calculates the correct width
110      *
111      * Column width can be given as fixed char widths, percentages and a single * width can be given
112      * for taking the remaining available space. When mixing percentages and fixed widths, percentages
113      * refer to the remaining space after allocating the fixed width
114      *
115      * @param array $columns
116      * @return int[]
117      * @throws Exception
118      */
119     protected function calculateColLengths($columns)
120     {
121         $idx = 0;
122         $border = $this->strlen($this->border);
123         $fixed = (count($columns) - 1) * $border; // borders are used already
124         $fluid = -1;
125 
126         // first pass for format check and fixed columns
127         foreach ($columns as $idx => $col) {
128             // handle fixed columns
129             if ((string)intval($col) === (string)$col) {
130                 $fixed += $col;
131                 continue;
132             }
133             // check if other colums are using proper units
134             if (substr($col, -1) == '%') {
135                 continue;
136             }
137             if ($col == '*') {
138                 // only one fluid
139                 if ($fluid < 0) {
140                     $fluid = $idx;
141                     continue;
142                 } else {
143                     throw new Exception('Only one fluid column allowed!');
144                 }
145             }
146             throw new Exception("unknown column format $col");
147         }
148 
149         $alloc = $fixed;
150         $remain = $this->max - $alloc;
151 
152         // second pass to handle percentages
153         foreach ($columns as $idx => $col) {
154             if (substr($col, -1) != '%') {
155                 continue;
156             }
157             $perc = floatval($col);
158 
159             $real = (int)floor(($perc * $remain) / 100);
160 
161             $columns[$idx] = $real;
162             $alloc += $real;
163         }
164 
165         $remain = $this->max - $alloc;
166         if ($remain < 0) {
167             throw new Exception("Wanted column widths exceed available space");
168         }
169 
170         // assign remaining space
171         if ($fluid < 0) {
172             $columns[$idx] += ($remain); // add to last column
173         } else {
174             $columns[$fluid] = $remain;
175         }
176 
177         return $columns;
178     }
179 
180     /**
181      * Displays text in multiple word wrapped columns
182      *
183      * @param int[] $columns list of column widths (in characters, percent or '*')
184      * @param string[] $texts list of texts for each column
185      * @param array $colors A list of color names to use for each column. use empty string for default
186      * @return string
187      * @throws Exception
188      */
189     public function format($columns, $texts, $colors = array())
190     {
191         $columns = $this->calculateColLengths($columns);
192 
193         $wrapped = array();
194         $maxlen = 0;
195 
196         foreach ($columns as $col => $width) {
197             $wrapped[$col] = explode("\n", $this->wordwrap($texts[$col], $width, "\n", true));
198             $len = count($wrapped[$col]);
199             if ($len > $maxlen) {
200                 $maxlen = $len;
201             }
202 
203         }
204 
205         $last = count($columns) - 1;
206         $out = '';
207         for ($i = 0; $i < $maxlen; $i++) {
208             foreach ($columns as $col => $width) {
209                 if (isset($wrapped[$col][$i])) {
210                     $val = $wrapped[$col][$i];
211                 } else {
212                     $val = '';
213                 }
214                 $chunk = $this->pad($val, $width);
215                 if (isset($colors[$col]) && $colors[$col]) {
216                     $chunk = $this->colors->wrap($chunk, $colors[$col]);
217                 }
218                 $out .= $chunk;
219 
220                 // border
221                 if ($col != $last) {
222                     $out .= $this->border;
223                 }
224             }
225             $out .= "\n";
226         }
227         return $out;
228 
229     }
230 
231     /**
232      * Pad the given string to the correct length
233      *
234      * @param string $string
235      * @param int $len
236      * @return string
237      */
238     protected function pad($string, $len)
239     {
240         $strlen = $this->strlen($string);
241         if ($strlen > $len) return $string;
242 
243         $pad = $len - $strlen;
244         return $string . str_pad('', $pad, ' ');
245     }
246 
247     /**
248      * Measures char length in UTF-8 when possible
249      *
250      * @param $string
251      * @return int
252      */
253     protected function strlen($string)
254     {
255         // don't count color codes
256         $string = preg_replace("/\33\\[\\d+(;\\d+)?m/", '', $string);
257 
258         if (function_exists('mb_strlen')) {
259             return mb_strlen($string, 'utf-8');
260         }
261 
262         return strlen($string);
263     }
264 
265     /**
266      * @param string $string
267      * @param int $start
268      * @param int|null $length
269      * @return string
270      */
271     protected function substr($string, $start = 0, $length = null)
272     {
273         if (function_exists('mb_substr')) {
274             return mb_substr($string, $start, $length);
275         } else {
276             // mb_substr() treats $length differently than substr()
277             if ($length) {
278                 return substr($string, $start, $length);
279             } else {
280                 return substr($string, $start);
281             }
282         }
283     }
284 
285     /**
286      * @param string $str
287      * @param int $width
288      * @param string $break
289      * @param bool $cut
290      * @return string
291      * @link http://stackoverflow.com/a/4988494
292      */
293     protected function wordwrap($str, $width = 75, $break = "\n", $cut = false)
294     {
295         $lines = explode($break, $str);
296         $color_reset = $this->colors->getColorCode(Colors::C_RESET);
297         foreach ($lines as &$line) {
298             $line = rtrim($line);
299             if ($this->strlen($line) <= $width) {
300                 continue;
301             }
302             $words = explode(' ', $line);
303             $line = '';
304             $actual = '';
305             $color = '';
306             foreach ($words as $word) {
307                 if (preg_match_all(Colors::C_CODE_REGEX, $word, $color_codes) ) {
308                     # Word contains color codes
309                     foreach ($color_codes[0] as $code) {
310                         if ($code == $color_reset) {
311                             $color = '';
312                         } else {
313                             # Remember color so we can reapply it after a line break
314                             $color = $code;
315                         }
316                     }
317                 }
318                 if ($this->strlen($actual . $word) <= $width) {
319                     $actual .= $word . ' ';
320                 } else {
321                     if ($actual != '') {
322                         $line .= rtrim($actual) . $break;
323                     }
324                     $actual = $color . $word;
325                     if ($cut) {
326                         while ($this->strlen($actual) > $width) {
327                             $line .= $this->substr($actual, 0, $width) . $break;
328                             $actual = $color . $this->substr($actual, $width);
329                         }
330                     }
331                     $actual .= ' ';
332                 }
333             }
334             $line .= trim($actual);
335         }
336         return implode($break, $lines);
337     }
338 }
339