1<?php
2
3namespace 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 */
13class 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        foreach ($lines as &$line) {
297            $line = rtrim($line);
298            if ($this->strlen($line) <= $width) {
299                continue;
300            }
301            $words = explode(' ', $line);
302            $line = '';
303            $actual = '';
304            foreach ($words as $word) {
305                if ($this->strlen($actual . $word) <= $width) {
306                    $actual .= $word . ' ';
307                } else {
308                    if ($actual != '') {
309                        $line .= rtrim($actual) . $break;
310                    }
311                    $actual = $word;
312                    if ($cut) {
313                        while ($this->strlen($actual) > $width) {
314                            $line .= $this->substr($actual, 0, $width) . $break;
315                            $actual = $this->substr($actual, $width);
316                        }
317                    }
318                    $actual .= ' ';
319                }
320            }
321            $line .= trim($actual);
322        }
323        return implode($break, $lines);
324    }
325}