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