| 1 |
<?php |
| 2 |
|
| 3 |
/* |
| 4 |
* This file is part of the Symfony package. |
| 5 |
* |
| 6 |
* (c) Fabien Potencier <fabien@symfony.com> |
| 7 |
* |
| 8 |
* For the full copyright and license information, please view the LICENSE |
| 9 |
* file that was distributed with this source code. |
| 10 |
*/ |
| 11 |
|
| 12 |
namespace Symfony\Component\Console\Helper; |
| 13 |
|
| 14 |
use Symfony\Component\Console\Exception\InvalidArgumentException; |
| 15 |
use Symfony\Component\Console\Exception\RuntimeException; |
| 16 |
use Symfony\Component\Console\Formatter\OutputFormatter; |
| 17 |
use Symfony\Component\Console\Formatter\WrappableOutputFormatterInterface; |
| 18 |
use Symfony\Component\Console\Output\ConsoleSectionOutput; |
| 19 |
use Symfony\Component\Console\Output\OutputInterface; |
| 20 |
|
| 21 |
/** |
| 22 |
* Provides helpers to display a table. |
| 23 |
* |
| 24 |
* @author Fabien Potencier <fabien@symfony.com> |
| 25 |
* @author Саша Стаменковић <umpirsky@gmail.com> |
| 26 |
* @author Abdellatif Ait boudad <a.aitboudad@gmail.com> |
| 27 |
* @author Max Grigorian <maxakawizard@gmail.com> |
| 28 |
* @author Dany Maillard <danymaillard93b@gmail.com> |
| 29 |
*/ |
| 30 |
class Table |
| 31 |
{ |
| 32 |
private const SEPARATOR_TOP = 0; |
| 33 |
private const SEPARATOR_TOP_BOTTOM = 1; |
| 34 |
private const SEPARATOR_MID = 2; |
| 35 |
private const SEPARATOR_BOTTOM = 3; |
| 36 |
private const BORDER_OUTSIDE = 0; |
| 37 |
private const BORDER_INSIDE = 1; |
| 38 |
|
| 39 |
private $headerTitle; |
| 40 |
private $footerTitle; |
| 41 |
|
| 42 |
/** |
| 43 |
* Table headers. |
| 44 |
*/ |
| 45 |
private $headers = []; |
| 46 |
|
| 47 |
/** |
| 48 |
* Table rows. |
| 49 |
*/ |
| 50 |
private $rows = []; |
| 51 |
private $horizontal = false; |
| 52 |
|
| 53 |
/** |
| 54 |
* Column widths cache. |
| 55 |
*/ |
| 56 |
private $effectiveColumnWidths = []; |
| 57 |
|
| 58 |
/** |
| 59 |
* Number of columns cache. |
| 60 |
* |
| 61 |
* @var int |
| 62 |
*/ |
| 63 |
private $numberOfColumns; |
| 64 |
|
| 65 |
/** |
| 66 |
* @var OutputInterface |
| 67 |
*/ |
| 68 |
private $output; |
| 69 |
|
| 70 |
/** |
| 71 |
* @var TableStyle |
| 72 |
*/ |
| 73 |
private $style; |
| 74 |
|
| 75 |
/** |
| 76 |
* @var array |
| 77 |
*/ |
| 78 |
private $columnStyles = []; |
| 79 |
|
| 80 |
/** |
| 81 |
* User set column widths. |
| 82 |
* |
| 83 |
* @var array |
| 84 |
*/ |
| 85 |
private $columnWidths = []; |
| 86 |
private $columnMaxWidths = []; |
| 87 |
|
| 88 |
/** |
| 89 |
* @var array<string, TableStyle>|null |
| 90 |
*/ |
| 91 |
private static $styles; |
| 92 |
|
| 93 |
private $rendered = false; |
| 94 |
|
| 95 |
public function __construct(OutputInterface $output) |
| 96 |
{ |
| 97 |
$this->output = $output; |
| 98 |
|
| 99 |
if (!self::$styles) { |
| 100 |
self::$styles = self::initStyles(); |
| 101 |
} |
| 102 |
|
| 103 |
$this->setStyle('default'); |
| 104 |
} |
| 105 |
|
| 106 |
/** |
| 107 |
* Sets a style definition. |
| 108 |
*/ |
| 109 |
public static function setStyleDefinition(string $name, TableStyle $style) |
| 110 |
{ |
| 111 |
if (!self::$styles) { |
| 112 |
self::$styles = self::initStyles(); |
| 113 |
} |
| 114 |
|
| 115 |
self::$styles[$name] = $style; |
| 116 |
} |
| 117 |
|
| 118 |
/** |
| 119 |
* Gets a style definition by name. |
| 120 |
* |
| 121 |
* @return TableStyle |
| 122 |
*/ |
| 123 |
public static function getStyleDefinition(string $name) |
| 124 |
{ |
| 125 |
if (!self::$styles) { |
| 126 |
self::$styles = self::initStyles(); |
| 127 |
} |
| 128 |
|
| 129 |
if (isset(self::$styles[$name])) { |
| 130 |
return self::$styles[$name]; |
| 131 |
} |
| 132 |
|
| 133 |
throw new InvalidArgumentException(sprintf('Style "%s" is not defined.', $name)); |
| 134 |
} |
| 135 |
|
| 136 |
/** |
| 137 |
* Sets table style. |
| 138 |
* |
| 139 |
* @param TableStyle|string $name The style name or a TableStyle instance |
| 140 |
* |
| 141 |
* @return $this |
| 142 |
*/ |
| 143 |
public function setStyle($name) |
| 144 |
{ |
| 145 |
$this->style = $this->resolveStyle($name); |
| 146 |
|
| 147 |
return $this; |
| 148 |
} |
| 149 |
|
| 150 |
/** |
| 151 |
* Gets the current table style. |
| 152 |
* |
| 153 |
* @return TableStyle |
| 154 |
*/ |
| 155 |
public function getStyle() |
| 156 |
{ |
| 157 |
return $this->style; |
| 158 |
} |
| 159 |
|
| 160 |
/** |
| 161 |
* Sets table column style. |
| 162 |
* |
| 163 |
* @param TableStyle|string $name The style name or a TableStyle instance |
| 164 |
* |
| 165 |
* @return $this |
| 166 |
*/ |
| 167 |
public function setColumnStyle(int $columnIndex, $name) |
| 168 |
{ |
| 169 |
$this->columnStyles[$columnIndex] = $this->resolveStyle($name); |
| 170 |
|
| 171 |
return $this; |
| 172 |
} |
| 173 |
|
| 174 |
/** |
| 175 |
* Gets the current style for a column. |
| 176 |
* |
| 177 |
* If style was not set, it returns the global table style. |
| 178 |
* |
| 179 |
* @return TableStyle |
| 180 |
*/ |
| 181 |
public function getColumnStyle(int $columnIndex) |
| 182 |
{ |
| 183 |
return $this->columnStyles[$columnIndex] ?? $this->getStyle(); |
| 184 |
} |
| 185 |
|
| 186 |
/** |
| 187 |
* Sets the minimum width of a column. |
| 188 |
* |
| 189 |
* @return $this |
| 190 |
*/ |
| 191 |
public function setColumnWidth(int $columnIndex, int $width) |
| 192 |
{ |
| 193 |
$this->columnWidths[$columnIndex] = $width; |
| 194 |
|
| 195 |
return $this; |
| 196 |
} |
| 197 |
|
| 198 |
/** |
| 199 |
* Sets the minimum width of all columns. |
| 200 |
* |
| 201 |
* @return $this |
| 202 |
*/ |
| 203 |
public function setColumnWidths(array $widths) |
| 204 |
{ |
| 205 |
$this->columnWidths = []; |
| 206 |
foreach ($widths as $index => $width) { |
| 207 |
$this->setColumnWidth($index, $width); |
| 208 |
} |
| 209 |
|
| 210 |
return $this; |
| 211 |
} |
| 212 |
|
| 213 |
/** |
| 214 |
* Sets the maximum width of a column. |
| 215 |
* |
| 216 |
* Any cell within this column which contents exceeds the specified width will be wrapped into multiple lines, while |
| 217 |
* formatted strings are preserved. |
| 218 |
* |
| 219 |
* @return $this |
| 220 |
*/ |
| 221 |
public function setColumnMaxWidth(int $columnIndex, int $width): self |
| 222 |
{ |
| 223 |
if (!$this->output->getFormatter() instanceof WrappableOutputFormatterInterface) { |
| 224 |
throw new \LogicException(sprintf('Setting a maximum column width is only supported when using a "%s" formatter, got "%s".', WrappableOutputFormatterInterface::class, get_debug_type($this->output->getFormatter()))); |
| 225 |
} |
| 226 |
|
| 227 |
$this->columnMaxWidths[$columnIndex] = $width; |
| 228 |
|
| 229 |
return $this; |
| 230 |
} |
| 231 |
|
| 232 |
/** |
| 233 |
* @return $this |
| 234 |
*/ |
| 235 |
public function setHeaders(array $headers) |
| 236 |
{ |
| 237 |
$headers = array_values($headers); |
| 238 |
if (!empty($headers) && !\is_array($headers[0])) { |
| 239 |
$headers = [$headers]; |
| 240 |
} |
| 241 |
|
| 242 |
$this->headers = $headers; |
| 243 |
|
| 244 |
return $this; |
| 245 |
} |
| 246 |
|
| 247 |
public function setRows(array $rows) |
| 248 |
{ |
| 249 |
$this->rows = []; |
| 250 |
|
| 251 |
return $this->addRows($rows); |
| 252 |
} |
| 253 |
|
| 254 |
/** |
| 255 |
* @return $this |
| 256 |
*/ |
| 257 |
public function addRows(array $rows) |
| 258 |
{ |
| 259 |
foreach ($rows as $row) { |
| 260 |
$this->addRow($row); |
| 261 |
} |
| 262 |
|
| 263 |
return $this; |
| 264 |
} |
| 265 |
|
| 266 |
/** |
| 267 |
* @return $this |
| 268 |
*/ |
| 269 |
public function addRow($row) |
| 270 |
{ |
| 271 |
if ($row instanceof TableSeparator) { |
| 272 |
$this->rows[] = $row; |
| 273 |
|
| 274 |
return $this; |
| 275 |
} |
| 276 |
|
| 277 |
if (!\is_array($row)) { |
| 278 |
throw new InvalidArgumentException('A row must be an array or a TableSeparator instance.'); |
| 279 |
} |
| 280 |
|
| 281 |
$this->rows[] = array_values($row); |
| 282 |
|
| 283 |
return $this; |
| 284 |
} |
| 285 |
|
| 286 |
/** |
| 287 |
* Adds a row to the table, and re-renders the table. |
| 288 |
* |
| 289 |
* @return $this |
| 290 |
*/ |
| 291 |
public function appendRow($row): self |
| 292 |
{ |
| 293 |
if (!$this->output instanceof ConsoleSectionOutput) { |
| 294 |
throw new RuntimeException(sprintf('Output should be an instance of "%s" when calling "%s".', ConsoleSectionOutput::class, __METHOD__)); |
| 295 |
} |
| 296 |
|
| 297 |
if ($this->rendered) { |
| 298 |
$this->output->clear($this->calculateRowCount()); |
| 299 |
} |
| 300 |
|
| 301 |
$this->addRow($row); |
| 302 |
$this->render(); |
| 303 |
|
| 304 |
return $this; |
| 305 |
} |
| 306 |
|
| 307 |
/** |
| 308 |
* @return $this |
| 309 |
*/ |
| 310 |
public function setRow($column, array $row) |
| 311 |
{ |
| 312 |
$this->rows[$column] = $row; |
| 313 |
|
| 314 |
return $this; |
| 315 |
} |
| 316 |
|
| 317 |
/** |
| 318 |
* @return $this |
| 319 |
*/ |
| 320 |
public function setHeaderTitle(?string $title): self |
| 321 |
{ |
| 322 |
$this->headerTitle = $title; |
| 323 |
|
| 324 |
return $this; |
| 325 |
} |
| 326 |
|
| 327 |
/** |
| 328 |
* @return $this |
| 329 |
*/ |
| 330 |
public function setFooterTitle(?string $title): self |
| 331 |
{ |
| 332 |
$this->footerTitle = $title; |
| 333 |
|
| 334 |
return $this; |
| 335 |
} |
| 336 |
|
| 337 |
/** |
| 338 |
* @return $this |
| 339 |
*/ |
| 340 |
public function setHorizontal(bool $horizontal = true): self |
| 341 |
{ |
| 342 |
$this->horizontal = $horizontal; |
| 343 |
|
| 344 |
return $this; |
| 345 |
} |
| 346 |
|
| 347 |
/** |
| 348 |
* Renders table to output. |
| 349 |
* |
| 350 |
* Example: |
| 351 |
* |
| 352 |
* +---------------+-----------------------+------------------+ |
| 353 |
* | ISBN | Title | Author | |
| 354 |
* +---------------+-----------------------+------------------+ |
| 355 |
* | 99921-58-10-7 | Divine Comedy | Dante Alighieri | |
| 356 |
* | 9971-5-0210-0 | A Tale of Two Cities | Charles Dickens | |
| 357 |
* | 960-425-059-0 | The Lord of the Rings | J. R. R. Tolkien | |
| 358 |
* +---------------+-----------------------+------------------+ |
| 359 |
*/ |
| 360 |
public function render() |
| 361 |
{ |
| 362 |
$divider = new TableSeparator(); |
| 363 |
if ($this->horizontal) { |
| 364 |
$rows = []; |
| 365 |
foreach ($this->headers[0] ?? [] as $i => $header) { |
| 366 |
$rows[$i] = [$header]; |
| 367 |
foreach ($this->rows as $row) { |
| 368 |
if ($row instanceof TableSeparator) { |
| 369 |
continue; |
| 370 |
} |
| 371 |
if (isset($row[$i])) { |
| 372 |
$rows[$i][] = $row[$i]; |
| 373 |
} elseif ($rows[$i][0] instanceof TableCell && $rows[$i][0]->getColspan() >= 2) { |
| 374 |
// Noop, there is a "title" |
| 375 |
} else { |
| 376 |
$rows[$i][] = null; |
| 377 |
} |
| 378 |
} |
| 379 |
} |
| 380 |
} else { |
| 381 |
$rows = array_merge($this->headers, [$divider], $this->rows); |
| 382 |
} |
| 383 |
|
| 384 |
$this->calculateNumberOfColumns($rows); |
| 385 |
|
| 386 |
$rowGroups = $this->buildTableRows($rows); |
| 387 |
$this->calculateColumnsWidth($rowGroups); |
| 388 |
|
| 389 |
$isHeader = !$this->horizontal; |
| 390 |
$isFirstRow = $this->horizontal; |
| 391 |
$hasTitle = (bool) $this->headerTitle; |
| 392 |
|
| 393 |
foreach ($rowGroups as $rowGroup) { |
| 394 |
$isHeaderSeparatorRendered = false; |
| 395 |
|
| 396 |
foreach ($rowGroup as $row) { |
| 397 |
if ($divider === $row) { |
| 398 |
$isHeader = false; |
| 399 |
$isFirstRow = true; |
| 400 |
|
| 401 |
continue; |
| 402 |
} |
| 403 |
|
| 404 |
if ($row instanceof TableSeparator) { |
| 405 |
$this->renderRowSeparator(); |
| 406 |
|
| 407 |
continue; |
| 408 |
} |
| 409 |
|
| 410 |
if (!$row) { |
| 411 |
continue; |
| 412 |
} |
| 413 |
|
| 414 |
if ($isHeader && !$isHeaderSeparatorRendered) { |
| 415 |
$this->renderRowSeparator( |
| 416 |
$isHeader ? self::SEPARATOR_TOP : self::SEPARATOR_TOP_BOTTOM, |
| 417 |
$hasTitle ? $this->headerTitle : null, |
| 418 |
$hasTitle ? $this->style->getHeaderTitleFormat() : null |
| 419 |
); |
| 420 |
$hasTitle = false; |
| 421 |
$isHeaderSeparatorRendered = true; |
| 422 |
} |
| 423 |
|
| 424 |
if ($isFirstRow) { |
| 425 |
$this->renderRowSeparator( |
| 426 |
$isHeader ? self::SEPARATOR_TOP : self::SEPARATOR_TOP_BOTTOM, |
| 427 |
$hasTitle ? $this->headerTitle : null, |
| 428 |
$hasTitle ? $this->style->getHeaderTitleFormat() : null |
| 429 |
); |
| 430 |
$isFirstRow = false; |
| 431 |
$hasTitle = false; |
| 432 |
} |
| 433 |
|
| 434 |
if ($this->horizontal) { |
| 435 |
$this->renderRow($row, $this->style->getCellRowFormat(), $this->style->getCellHeaderFormat()); |
| 436 |
} else { |
| 437 |
$this->renderRow($row, $isHeader ? $this->style->getCellHeaderFormat() : $this->style->getCellRowFormat()); |
| 438 |
} |
| 439 |
} |
| 440 |
} |
| 441 |
$this->renderRowSeparator(self::SEPARATOR_BOTTOM, $this->footerTitle, $this->style->getFooterTitleFormat()); |
| 442 |
|
| 443 |
$this->cleanup(); |
| 444 |
$this->rendered = true; |
| 445 |
} |
| 446 |
|
| 447 |
/** |
| 448 |
* Renders horizontal header separator. |
| 449 |
* |
| 450 |
* Example: |
| 451 |
* |
| 452 |
* +-----+-----------+-------+ |
| 453 |
*/ |
| 454 |
private function renderRowSeparator(int $type = self::SEPARATOR_MID, ?string $title = null, ?string $titleFormat = null) |
| 455 |
{ |
| 456 |
if (0 === $count = $this->numberOfColumns) { |
| 457 |
return; |
| 458 |
} |
| 459 |
|
| 460 |
$borders = $this->style->getBorderChars(); |
| 461 |
if (!$borders[0] && !$borders[2] && !$this->style->getCrossingChar()) { |
| 462 |
return; |
| 463 |
} |
| 464 |
|
| 465 |
$crossings = $this->style->getCrossingChars(); |
| 466 |
if (self::SEPARATOR_MID === $type) { |
| 467 |
[$horizontal, $leftChar, $midChar, $rightChar] = [$borders[2], $crossings[8], $crossings[0], $crossings[4]]; |
| 468 |
} elseif (self::SEPARATOR_TOP === $type) { |
| 469 |
[$horizontal, $leftChar, $midChar, $rightChar] = [$borders[0], $crossings[1], $crossings[2], $crossings[3]]; |
| 470 |
} elseif (self::SEPARATOR_TOP_BOTTOM === $type) { |
| 471 |
[$horizontal, $leftChar, $midChar, $rightChar] = [$borders[0], $crossings[9], $crossings[10], $crossings[11]]; |
| 472 |
} else { |
| 473 |
[$horizontal, $leftChar, $midChar, $rightChar] = [$borders[0], $crossings[7], $crossings[6], $crossings[5]]; |
| 474 |
} |
| 475 |
|
| 476 |
$markup = $leftChar; |
| 477 |
for ($column = 0; $column < $count; ++$column) { |
| 478 |
$markup .= str_repeat($horizontal, $this->effectiveColumnWidths[$column]); |
| 479 |
$markup .= $column === $count - 1 ? $rightChar : $midChar; |
| 480 |
} |
| 481 |
|
| 482 |
if (null !== $title) { |
| 483 |
$titleLength = Helper::width(Helper::removeDecoration($formatter = $this->output->getFormatter(), $formattedTitle = sprintf($titleFormat, $title))); |
| 484 |
$markupLength = Helper::width($markup); |
| 485 |
if ($titleLength > $limit = $markupLength - 4) { |
| 486 |
$titleLength = $limit; |
| 487 |
$formatLength = Helper::width(Helper::removeDecoration($formatter, sprintf($titleFormat, ''))); |
| 488 |
$formattedTitle = sprintf($titleFormat, Helper::substr($title, 0, $limit - $formatLength - 3).'...'); |
| 489 |
} |
| 490 |
|
| 491 |
$titleStart = intdiv($markupLength - $titleLength, 2); |
| 492 |
if (false === mb_detect_encoding($markup, null, true)) { |
| 493 |
$markup = substr_replace($markup, $formattedTitle, $titleStart, $titleLength); |
| 494 |
} else { |
| 495 |
$markup = mb_substr($markup, 0, $titleStart).$formattedTitle.mb_substr($markup, $titleStart + $titleLength); |
| 496 |
} |
| 497 |
} |
| 498 |
|
| 499 |
$this->output->writeln(sprintf($this->style->getBorderFormat(), $markup)); |
| 500 |
} |
| 501 |
|
| 502 |
/** |
| 503 |
* Renders vertical column separator. |
| 504 |
*/ |
| 505 |
private function renderColumnSeparator(int $type = self::BORDER_OUTSIDE): string |
| 506 |
{ |
| 507 |
$borders = $this->style->getBorderChars(); |
| 508 |
|
| 509 |
return sprintf($this->style->getBorderFormat(), self::BORDER_OUTSIDE === $type ? $borders[1] : $borders[3]); |
| 510 |
} |
| 511 |
|
| 512 |
/** |
| 513 |
* Renders table row. |
| 514 |
* |
| 515 |
* Example: |
| 516 |
* |
| 517 |
* | 9971-5-0210-0 | A Tale of Two Cities | Charles Dickens | |
| 518 |
*/ |
| 519 |
private function renderRow(array $row, string $cellFormat, ?string $firstCellFormat = null) |
| 520 |
{ |
| 521 |
$rowContent = $this->renderColumnSeparator(self::BORDER_OUTSIDE); |
| 522 |
$columns = $this->getRowColumns($row); |
| 523 |
$last = \count($columns) - 1; |
| 524 |
foreach ($columns as $i => $column) { |
| 525 |
if ($firstCellFormat && 0 === $i) { |
| 526 |
$rowContent .= $this->renderCell($row, $column, $firstCellFormat); |
| 527 |
} else { |
| 528 |
$rowContent .= $this->renderCell($row, $column, $cellFormat); |
| 529 |
} |
| 530 |
$rowContent .= $this->renderColumnSeparator($last === $i ? self::BORDER_OUTSIDE : self::BORDER_INSIDE); |
| 531 |
} |
| 532 |
$this->output->writeln($rowContent); |
| 533 |
} |
| 534 |
|
| 535 |
/** |
| 536 |
* Renders table cell with padding. |
| 537 |
*/ |
| 538 |
private function renderCell(array $row, int $column, string $cellFormat): string |
| 539 |
{ |
| 540 |
$cell = $row[$column] ?? ''; |
| 541 |
$width = $this->effectiveColumnWidths[$column]; |
| 542 |
if ($cell instanceof TableCell && $cell->getColspan() > 1) { |
| 543 |
// add the width of the following columns(numbers of colspan). |
| 544 |
foreach (range($column + 1, $column + $cell->getColspan() - 1) as $nextColumn) { |
| 545 |
$width += $this->getColumnSeparatorWidth() + $this->effectiveColumnWidths[$nextColumn]; |
| 546 |
} |
| 547 |
} |
| 548 |
|
| 549 |
// str_pad won't work properly with multi-byte strings, we need to fix the padding |
| 550 |
if (false !== $encoding = mb_detect_encoding($cell, null, true)) { |
| 551 |
$width += \strlen($cell) - mb_strwidth($cell, $encoding); |
| 552 |
} |
| 553 |
|
| 554 |
$style = $this->getColumnStyle($column); |
| 555 |
|
| 556 |
if ($cell instanceof TableSeparator) { |
| 557 |
return sprintf($style->getBorderFormat(), str_repeat($style->getBorderChars()[2], $width)); |
| 558 |
} |
| 559 |
|
| 560 |
$width += Helper::length($cell) - Helper::length(Helper::removeDecoration($this->output->getFormatter(), $cell)); |
| 561 |
$content = sprintf($style->getCellRowContentFormat(), $cell); |
| 562 |
|
| 563 |
$padType = $style->getPadType(); |
| 564 |
if ($cell instanceof TableCell && $cell->getStyle() instanceof TableCellStyle) { |
| 565 |
$isNotStyledByTag = !preg_match('/^<(\w+|(\w+=[\w,]+;?)*)>.+<\/(\w+|(\w+=\w+;?)*)?>$/', $cell); |
| 566 |
if ($isNotStyledByTag) { |
| 567 |
$cellFormat = $cell->getStyle()->getCellFormat(); |
| 568 |
if (!\is_string($cellFormat)) { |
| 569 |
$tag = http_build_query($cell->getStyle()->getTagOptions(), '', ';'); |
| 570 |
$cellFormat = '<'.$tag.'>%s</>'; |
| 571 |
} |
| 572 |
|
| 573 |
if (strstr($content, '</>')) { |
| 574 |
$content = str_replace('</>', '', $content); |
| 575 |
$width -= 3; |
| 576 |
} |
| 577 |
if (strstr($content, '<fg=default;bg=default>')) { |
| 578 |
$content = str_replace('<fg=default;bg=default>', '', $content); |
| 579 |
$width -= \strlen('<fg=default;bg=default>'); |
| 580 |
} |
| 581 |
} |
| 582 |
|
| 583 |
$padType = $cell->getStyle()->getPadByAlign(); |
| 584 |
} |
| 585 |
|
| 586 |
return sprintf($cellFormat, str_pad($content, $width, $style->getPaddingChar(), $padType)); |
| 587 |
} |
| 588 |
|
| 589 |
/** |
| 590 |
* Calculate number of columns for this table. |
| 591 |
*/ |
| 592 |
private function calculateNumberOfColumns(array $rows) |
| 593 |
{ |
| 594 |
$columns = [0]; |
| 595 |
foreach ($rows as $row) { |
| 596 |
if ($row instanceof TableSeparator) { |
| 597 |
continue; |
| 598 |
} |
| 599 |
|
| 600 |
$columns[] = $this->getNumberOfColumns($row); |
| 601 |
} |
| 602 |
|
| 603 |
$this->numberOfColumns = max($columns); |
| 604 |
} |
| 605 |
|
| 606 |
private function buildTableRows(array $rows): TableRows |
| 607 |
{ |
| 608 |
/** @var WrappableOutputFormatterInterface $formatter */ |
| 609 |
$formatter = $this->output->getFormatter(); |
| 610 |
$unmergedRows = []; |
| 611 |
for ($rowKey = 0; $rowKey < \count($rows); ++$rowKey) { |
| 612 |
$rows = $this->fillNextRows($rows, $rowKey); |
| 613 |
|
| 614 |
// Remove any new line breaks and replace it with a new line |
| 615 |
foreach ($rows[$rowKey] as $column => $cell) { |
| 616 |
$colspan = $cell instanceof TableCell ? $cell->getColspan() : 1; |
| 617 |
|
| 618 |
if (isset($this->columnMaxWidths[$column]) && Helper::width(Helper::removeDecoration($formatter, $cell)) > $this->columnMaxWidths[$column]) { |
| 619 |
$cell = $formatter->formatAndWrap($cell, $this->columnMaxWidths[$column] * $colspan); |
| 620 |
} |
| 621 |
if (!strstr($cell ?? '', "\n")) { |
| 622 |
continue; |
| 623 |
} |
| 624 |
$eol = str_contains($cell ?? '', "\r\n") ? "\r\n" : "\n"; |
| 625 |
$escaped = implode($eol, array_map([OutputFormatter::class, 'escapeTrailingBackslash'], explode($eol, $cell))); |
| 626 |
$cell = $cell instanceof TableCell ? new TableCell($escaped, ['colspan' => $cell->getColspan()]) : $escaped; |
| 627 |
$lines = explode($eol, str_replace($eol, '<fg=default;bg=default></>'.$eol, $cell)); |
| 628 |
foreach ($lines as $lineKey => $line) { |
| 629 |
if ($colspan > 1) { |
| 630 |
$line = new TableCell($line, ['colspan' => $colspan]); |
| 631 |
} |
| 632 |
if (0 === $lineKey) { |
| 633 |
$rows[$rowKey][$column] = $line; |
| 634 |
} else { |
| 635 |
if (!\array_key_exists($rowKey, $unmergedRows) || !\array_key_exists($lineKey, $unmergedRows[$rowKey])) { |
| 636 |
$unmergedRows[$rowKey][$lineKey] = $this->copyRow($rows, $rowKey); |
| 637 |
} |
| 638 |
$unmergedRows[$rowKey][$lineKey][$column] = $line; |
| 639 |
} |
| 640 |
} |
| 641 |
} |
| 642 |
} |
| 643 |
|
| 644 |
return new TableRows(function () use ($rows, $unmergedRows): \Traversable { |
| 645 |
foreach ($rows as $rowKey => $row) { |
| 646 |
$rowGroup = [$row instanceof TableSeparator ? $row : $this->fillCells($row)]; |
| 647 |
|
| 648 |
if (isset($unmergedRows[$rowKey])) { |
| 649 |
foreach ($unmergedRows[$rowKey] as $row) { |
| 650 |
$rowGroup[] = $row instanceof TableSeparator ? $row : $this->fillCells($row); |
| 651 |
} |
| 652 |
} |
| 653 |
yield $rowGroup; |
| 654 |
} |
| 655 |
}); |
| 656 |
} |
| 657 |
|
| 658 |
private function calculateRowCount(): int |
| 659 |
{ |
| 660 |
$numberOfRows = \count(iterator_to_array($this->buildTableRows(array_merge($this->headers, [new TableSeparator()], $this->rows)))); |
| 661 |
|
| 662 |
if ($this->headers) { |
| 663 |
++$numberOfRows; // Add row for header separator |
| 664 |
} |
| 665 |
|
| 666 |
if (\count($this->rows) > 0) { |
| 667 |
++$numberOfRows; // Add row for footer separator |
| 668 |
} |
| 669 |
|
| 670 |
return $numberOfRows; |
| 671 |
} |
| 672 |
|
| 673 |
/** |
| 674 |
* fill rows that contains rowspan > 1. |
| 675 |
* |
| 676 |
* @throws InvalidArgumentException |
| 677 |
*/ |
| 678 |
private function fillNextRows(array $rows, int $line): array |
| 679 |
{ |
| 680 |
$unmergedRows = []; |
| 681 |
foreach ($rows[$line] as $column => $cell) { |
| 682 |
if (null !== $cell && !$cell instanceof TableCell && !\is_scalar($cell) && !(\is_object($cell) && method_exists($cell, '__toString'))) { |
| 683 |
throw new InvalidArgumentException(sprintf('A cell must be a TableCell, a scalar or an object implementing "__toString()", "%s" given.', get_debug_type($cell))); |
| 684 |
} |
| 685 |
if ($cell instanceof TableCell && $cell->getRowspan() > 1) { |
| 686 |
$nbLines = $cell->getRowspan() - 1; |
| 687 |
$lines = [$cell]; |
| 688 |
if (strstr($cell, "\n")) { |
| 689 |
$eol = str_contains($cell, "\r\n") ? "\r\n" : "\n"; |
| 690 |
$lines = explode($eol, str_replace($eol, '<fg=default;bg=default>'.$eol.'</>', $cell)); |
| 691 |
$nbLines = \count($lines) > $nbLines ? substr_count($cell, $eol) : $nbLines; |
| 692 |
|
| 693 |
$rows[$line][$column] = new TableCell($lines[0], ['colspan' => $cell->getColspan(), 'style' => $cell->getStyle()]); |
| 694 |
unset($lines[0]); |
| 695 |
} |
| 696 |
|
| 697 |
// create a two dimensional array (rowspan x colspan) |
| 698 |
$unmergedRows = array_replace_recursive(array_fill($line + 1, $nbLines, []), $unmergedRows); |
| 699 |
foreach ($unmergedRows as $unmergedRowKey => $unmergedRow) { |
| 700 |
$value = $lines[$unmergedRowKey - $line] ?? ''; |
| 701 |
$unmergedRows[$unmergedRowKey][$column] = new TableCell($value, ['colspan' => $cell->getColspan(), 'style' => $cell->getStyle()]); |
| 702 |
if ($nbLines === $unmergedRowKey - $line) { |
| 703 |
break; |
| 704 |
} |
| 705 |
} |
| 706 |
} |
| 707 |
} |
| 708 |
|
| 709 |
foreach ($unmergedRows as $unmergedRowKey => $unmergedRow) { |
| 710 |
// we need to know if $unmergedRow will be merged or inserted into $rows |
| 711 |
if (isset($rows[$unmergedRowKey]) && \is_array($rows[$unmergedRowKey]) && ($this->getNumberOfColumns($rows[$unmergedRowKey]) + $this->getNumberOfColumns($unmergedRows[$unmergedRowKey]) <= $this->numberOfColumns)) { |
| 712 |
foreach ($unmergedRow as $cellKey => $cell) { |
| 713 |
// insert cell into row at cellKey position |
| 714 |
array_splice($rows[$unmergedRowKey], $cellKey, 0, [$cell]); |
| 715 |
} |
| 716 |
} else { |
| 717 |
$row = $this->copyRow($rows, $unmergedRowKey - 1); |
| 718 |
foreach ($unmergedRow as $column => $cell) { |
| 719 |
if (!empty($cell)) { |
| 720 |
$row[$column] = $unmergedRow[$column]; |
| 721 |
} |
| 722 |
} |
| 723 |
array_splice($rows, $unmergedRowKey, 0, [$row]); |
| 724 |
} |
| 725 |
} |
| 726 |
|
| 727 |
return $rows; |
| 728 |
} |
| 729 |
|
| 730 |
/** |
| 731 |
* fill cells for a row that contains colspan > 1. |
| 732 |
*/ |
| 733 |
private function fillCells(iterable $row) |
| 734 |
{ |
| 735 |
$newRow = []; |
| 736 |
|
| 737 |
foreach ($row as $column => $cell) { |
| 738 |
$newRow[] = $cell; |
| 739 |
if ($cell instanceof TableCell && $cell->getColspan() > 1) { |
| 740 |
foreach (range($column + 1, $column + $cell->getColspan() - 1) as $position) { |
| 741 |
// insert empty value at column position |
| 742 |
$newRow[] = ''; |
| 743 |
} |
| 744 |
} |
| 745 |
} |
| 746 |
|
| 747 |
return $newRow ?: $row; |
| 748 |
} |
| 749 |
|
| 750 |
private function copyRow(array $rows, int $line): array |
| 751 |
{ |
| 752 |
$row = $rows[$line]; |
| 753 |
foreach ($row as $cellKey => $cellValue) { |
| 754 |
$row[$cellKey] = ''; |
| 755 |
if ($cellValue instanceof TableCell) { |
| 756 |
$row[$cellKey] = new TableCell('', ['colspan' => $cellValue->getColspan()]); |
| 757 |
} |
| 758 |
} |
| 759 |
|
| 760 |
return $row; |
| 761 |
} |
| 762 |
|
| 763 |
/** |
| 764 |
* Gets number of columns by row. |
| 765 |
*/ |
| 766 |
private function getNumberOfColumns(array $row): int |
| 767 |
{ |
| 768 |
$columns = \count($row); |
| 769 |
foreach ($row as $column) { |
| 770 |
$columns += $column instanceof TableCell ? ($column->getColspan() - 1) : 0; |
| 771 |
} |
| 772 |
|
| 773 |
return $columns; |
| 774 |
} |
| 775 |
|
| 776 |
/** |
| 777 |
* Gets list of columns for the given row. |
| 778 |
*/ |
| 779 |
private function getRowColumns(array $row): array |
| 780 |
{ |
| 781 |
$columns = range(0, $this->numberOfColumns - 1); |
| 782 |
foreach ($row as $cellKey => $cell) { |
| 783 |
if ($cell instanceof TableCell && $cell->getColspan() > 1) { |
| 784 |
// exclude grouped columns. |
| 785 |
$columns = array_diff($columns, range($cellKey + 1, $cellKey + $cell->getColspan() - 1)); |
| 786 |
} |
| 787 |
} |
| 788 |
|
| 789 |
return $columns; |
| 790 |
} |
| 791 |
|
| 792 |
/** |
| 793 |
* Calculates columns widths. |
| 794 |
*/ |
| 795 |
private function calculateColumnsWidth(iterable $groups) |
| 796 |
{ |
| 797 |
for ($column = 0; $column < $this->numberOfColumns; ++$column) { |
| 798 |
$lengths = []; |
| 799 |
foreach ($groups as $group) { |
| 800 |
foreach ($group as $row) { |
| 801 |
if ($row instanceof TableSeparator) { |
| 802 |
continue; |
| 803 |
} |
| 804 |
|
| 805 |
foreach ($row as $i => $cell) { |
| 806 |
if ($cell instanceof TableCell) { |
| 807 |
$textContent = Helper::removeDecoration($this->output->getFormatter(), $cell); |
| 808 |
$textLength = Helper::width($textContent); |
| 809 |
if ($textLength > 0) { |
| 810 |
$contentColumns = mb_str_split($textContent, ceil($textLength / $cell->getColspan())); |
| 811 |
foreach ($contentColumns as $position => $content) { |
| 812 |
$row[$i + $position] = $content; |
| 813 |
} |
| 814 |
} |
| 815 |
} |
| 816 |
} |
| 817 |
|
| 818 |
$lengths[] = $this->getCellWidth($row, $column); |
| 819 |
} |
| 820 |
} |
| 821 |
|
| 822 |
$this->effectiveColumnWidths[$column] = max($lengths) + Helper::width($this->style->getCellRowContentFormat()) - 2; |
| 823 |
} |
| 824 |
} |
| 825 |
|
| 826 |
private function getColumnSeparatorWidth(): int |
| 827 |
{ |
| 828 |
return Helper::width(sprintf($this->style->getBorderFormat(), $this->style->getBorderChars()[3])); |
| 829 |
} |
| 830 |
|
| 831 |
private function getCellWidth(array $row, int $column): int |
| 832 |
{ |
| 833 |
$cellWidth = 0; |
| 834 |
|
| 835 |
if (isset($row[$column])) { |
| 836 |
$cell = $row[$column]; |
| 837 |
$cellWidth = Helper::width(Helper::removeDecoration($this->output->getFormatter(), $cell)); |
| 838 |
} |
| 839 |
|
| 840 |
$columnWidth = $this->columnWidths[$column] ?? 0; |
| 841 |
$cellWidth = max($cellWidth, $columnWidth); |
| 842 |
|
| 843 |
return isset($this->columnMaxWidths[$column]) ? min($this->columnMaxWidths[$column], $cellWidth) : $cellWidth; |
| 844 |
} |
| 845 |
|
| 846 |
/** |
| 847 |
* Called after rendering to cleanup cache data. |
| 848 |
*/ |
| 849 |
private function cleanup() |
| 850 |
{ |
| 851 |
$this->effectiveColumnWidths = []; |
| 852 |
$this->numberOfColumns = null; |
| 853 |
} |
| 854 |
|
| 855 |
/** |
| 856 |
* @return array<string, TableStyle> |
| 857 |
*/ |
| 858 |
private static function initStyles(): array |
| 859 |
{ |
| 860 |
$borderless = new TableStyle(); |
| 861 |
$borderless |
| 862 |
->setHorizontalBorderChars('=') |
| 863 |
->setVerticalBorderChars(' ') |
| 864 |
->setDefaultCrossingChar(' ') |
| 865 |
; |
| 866 |
|
| 867 |
$compact = new TableStyle(); |
| 868 |
$compact |
| 869 |
->setHorizontalBorderChars('') |
| 870 |
->setVerticalBorderChars('') |
| 871 |
->setDefaultCrossingChar('') |
| 872 |
->setCellRowContentFormat('%s ') |
| 873 |
; |
| 874 |
|
| 875 |
$styleGuide = new TableStyle(); |
| 876 |
$styleGuide |
| 877 |
->setHorizontalBorderChars('-') |
| 878 |
->setVerticalBorderChars(' ') |
| 879 |
->setDefaultCrossingChar(' ') |
| 880 |
->setCellHeaderFormat('%s') |
| 881 |
; |
| 882 |
|
| 883 |
$box = (new TableStyle()) |
| 884 |
->setHorizontalBorderChars('─') |
| 885 |
->setVerticalBorderChars('│') |
| 886 |
->setCrossingChars('┼', '┌', '┬', '┐', '┤', '┘', '┴', '└', '├') |
| 887 |
; |
| 888 |
|
| 889 |
$boxDouble = (new TableStyle()) |
| 890 |
->setHorizontalBorderChars('═', '─') |
| 891 |
->setVerticalBorderChars('║', '│') |
| 892 |
->setCrossingChars('┼', '╔', '╤', '╗', '╢', '╝', '╧', '╚', '╟', '╠', '╪', '╣') |
| 893 |
; |
| 894 |
|
| 895 |
return [ |
| 896 |
'default' => new TableStyle(), |
| 897 |
'borderless' => $borderless, |
| 898 |
'compact' => $compact, |
| 899 |
'symfony-style-guide' => $styleGuide, |
| 900 |
'box' => $box, |
| 901 |
'box-double' => $boxDouble, |
| 902 |
]; |
| 903 |
} |
| 904 |
|
| 905 |
private function resolveStyle($name): TableStyle |
| 906 |
{ |
| 907 |
if ($name instanceof TableStyle) { |
| 908 |
return $name; |
| 909 |
} |
| 910 |
|
| 911 |
if (isset(self::$styles[$name])) { |
| 912 |
return self::$styles[$name]; |
| 913 |
} |
| 914 |
|
| 915 |
throw new InvalidArgumentException(sprintf('Style "%s" is not defined.', $name)); |
| 916 |
} |
| 917 |
} |
| 918 |
|