Table.php 36 KB

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