Table.php 32 KB

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