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