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}