Table::calculateNumberOfColumns()   A
last analyzed

Complexity

Conditions 3
Paths 3

Size

Total Lines 12
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 6
c 1
b 0
f 0
dl 0
loc 12
rs 10
cc 3
nc 3
nop 1
1
<?php
2
3
/*
4
 * This file is part of the Symfony package.
5
 *
6
 * (c) Fabien Potencier <[email protected]>
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 <[email protected]>
25
 * @author Саша Стаменковић <[email protected]>
26
 * @author Abdellatif Ait boudad <[email protected]>
27
 * @author Max Grigorian <[email protected]>
28
 * @author Dany Maillard <[email protected]>
29
 */
30
class Table
31
{
32
    private const SEPARATOR_TOP = 0;
33
    private const SEPARATOR_TOP_BOTTOM = 1;
34
    private const SEPARATOR_MID = 2;
35
    private const SEPARATOR_BOTTOM = 3;
36
    private const BORDER_OUTSIDE = 0;
37
    private const BORDER_INSIDE = 1;
38
39
    private $headerTitle;
40
    private $footerTitle;
41
42
    /**
43
     * Table headers.
44
     */
45
    private $headers = [];
46
47
    /**
48
     * Table rows.
49
     */
50
    private $rows = [];
51
    private $horizontal = false;
52
53
    /**
54
     * Column widths cache.
55
     */
56
    private $effectiveColumnWidths = [];
57
58
    /**
59
     * Number of columns cache.
60
     *
61
     * @var int
62
     */
63
    private $numberOfColumns;
64
65
    /**
66
     * @var OutputInterface
67
     */
68
    private $output;
69
70
    /**
71
     * @var TableStyle
72
     */
73
    private $style;
74
75
    /**
76
     * @var array
77
     */
78
    private $columnStyles = [];
79
80
    /**
81
     * User set column widths.
82
     *
83
     * @var array
84
     */
85
    private $columnWidths = [];
86
    private $columnMaxWidths = [];
87
88
    private static $styles;
89
90
    private $rendered = false;
91
92
    public function __construct(OutputInterface $output)
93
    {
94
        $this->output = $output;
95
96
        if (!self::$styles) {
97
            self::$styles = self::initStyles();
98
        }
99
100
        $this->setStyle('default');
101
    }
102
103
    /**
104
     * Sets a style definition.
105
     */
106
    public static function setStyleDefinition(string $name, TableStyle $style)
107
    {
108
        if (!self::$styles) {
109
            self::$styles = self::initStyles();
110
        }
111
112
        self::$styles[$name] = $style;
113
    }
114
115
    /**
116
     * Gets a style definition by name.
117
     *
118
     * @return TableStyle
119
     */
120
    public static function getStyleDefinition(string $name)
121
    {
122
        if (!self::$styles) {
123
            self::$styles = self::initStyles();
124
        }
125
126
        if (isset(self::$styles[$name])) {
127
            return self::$styles[$name];
128
        }
129
130
        throw new InvalidArgumentException(sprintf('Style "%s" is not defined.', $name));
131
    }
132
133
    /**
134
     * Sets table style.
135
     *
136
     * @param TableStyle|string $name The style name or a TableStyle instance
137
     *
138
     * @return $this
139
     */
140
    public function setStyle($name)
141
    {
142
        $this->style = $this->resolveStyle($name);
143
144
        return $this;
145
    }
146
147
    /**
148
     * Gets the current table style.
149
     *
150
     * @return TableStyle
151
     */
152
    public function getStyle()
153
    {
154
        return $this->style;
155
    }
156
157
    /**
158
     * Sets table column style.
159
     *
160
     * @param TableStyle|string $name The style name or a TableStyle instance
161
     *
162
     * @return $this
163
     */
164
    public function setColumnStyle(int $columnIndex, $name)
165
    {
166
        $this->columnStyles[$columnIndex] = $this->resolveStyle($name);
167
168
        return $this;
169
    }
170
171
    /**
172
     * Gets the current style for a column.
173
     *
174
     * If style was not set, it returns the global table style.
175
     *
176
     * @return TableStyle
177
     */
178
    public function getColumnStyle(int $columnIndex)
179
    {
180
        return $this->columnStyles[$columnIndex] ?? $this->getStyle();
181
    }
182
183
    /**
184
     * Sets the minimum width of a column.
185
     *
186
     * @return $this
187
     */
188
    public function setColumnWidth(int $columnIndex, int $width)
189
    {
190
        $this->columnWidths[$columnIndex] = $width;
191
192
        return $this;
193
    }
194
195
    /**
196
     * Sets the minimum width of all columns.
197
     *
198
     * @return $this
199
     */
200
    public function setColumnWidths(array $widths)
201
    {
202
        $this->columnWidths = [];
203
        foreach ($widths as $index => $width) {
204
            $this->setColumnWidth($index, $width);
205
        }
206
207
        return $this;
208
    }
209
210
    /**
211
     * Sets the maximum width of a column.
212
     *
213
     * Any cell within this column which contents exceeds the specified width will be wrapped into multiple lines, while
214
     * formatted strings are preserved.
215
     *
216
     * @return $this
217
     */
218
    public function setColumnMaxWidth(int $columnIndex, int $width): self
219
    {
220
        if (!$this->output->getFormatter() instanceof WrappableOutputFormatterInterface) {
221
            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())));
222
        }
223
224
        $this->columnMaxWidths[$columnIndex] = $width;
225
226
        return $this;
227
    }
228
229
    public function setHeaders(array $headers)
230
    {
231
        $headers = array_values($headers);
232
        if (!empty($headers) && !\is_array($headers[0])) {
233
            $headers = [$headers];
234
        }
235
236
        $this->headers = $headers;
237
238
        return $this;
239
    }
240
241
    public function setRows(array $rows)
242
    {
243
        $this->rows = [];
244
245
        return $this->addRows($rows);
246
    }
247
248
    public function addRows(array $rows)
249
    {
250
        foreach ($rows as $row) {
251
            $this->addRow($row);
252
        }
253
254
        return $this;
255
    }
256
257
    public function addRow($row)
258
    {
259
        if ($row instanceof TableSeparator) {
260
            $this->rows[] = $row;
261
262
            return $this;
263
        }
264
265
        if (!\is_array($row)) {
266
            throw new InvalidArgumentException('A row must be an array or a TableSeparator instance.');
267
        }
268
269
        $this->rows[] = array_values($row);
270
271
        return $this;
272
    }
273
274
    /**
275
     * Adds a row to the table, and re-renders the table.
276
     */
277
    public function appendRow($row): self
278
    {
279
        if (!$this->output instanceof ConsoleSectionOutput) {
280
            throw new RuntimeException(sprintf('Output should be an instance of "%s" when calling "%s".', ConsoleSectionOutput::class, __METHOD__));
281
        }
282
283
        if ($this->rendered) {
284
            $this->output->clear($this->calculateRowCount());
285
        }
286
287
        $this->addRow($row);
288
        $this->render();
289
290
        return $this;
291
    }
292
293
    public function setRow($column, array $row)
294
    {
295
        $this->rows[$column] = $row;
296
297
        return $this;
298
    }
299
300
    public function setHeaderTitle(?string $title): self
301
    {
302
        $this->headerTitle = $title;
303
304
        return $this;
305
    }
306
307
    public function setFooterTitle(?string $title): self
308
    {
309
        $this->footerTitle = $title;
310
311
        return $this;
312
    }
313
314
    public function setHorizontal(bool $horizontal = true): self
315
    {
316
        $this->horizontal = $horizontal;
317
318
        return $this;
319
    }
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
    public function render()
335
    {
336
        $divider = new TableSeparator();
337
        if ($this->horizontal) {
338
            $rows = [];
339
            foreach ($this->headers[0] ?? [] as $i => $header) {
340
                $rows[$i] = [$header];
341
                foreach ($this->rows as $row) {
342
                    if ($row instanceof TableSeparator) {
343
                        continue;
344
                    }
345
                    if (isset($row[$i])) {
346
                        $rows[$i][] = $row[$i];
347
                    } elseif ($rows[$i][0] instanceof TableCell && $rows[$i][0]->getColspan() >= 2) {
348
                        // Noop, there is a "title"
349
                    } else {
350
                        $rows[$i][] = null;
351
                    }
352
                }
353
            }
354
        } else {
355
            $rows = array_merge($this->headers, [$divider], $this->rows);
356
        }
357
358
        $this->calculateNumberOfColumns($rows);
359
360
        $rows = $this->buildTableRows($rows);
361
        $this->calculateColumnsWidth($rows);
362
363
        $isHeader = !$this->horizontal;
364
        $isFirstRow = $this->horizontal;
365
        foreach ($rows as $row) {
366
            if ($divider === $row) {
367
                $isHeader = false;
368
                $isFirstRow = true;
369
370
                continue;
371
            }
372
            if ($row instanceof TableSeparator) {
373
                $this->renderRowSeparator();
374
375
                continue;
376
            }
377
            if (!$row) {
378
                continue;
379
            }
380
381
            if ($isHeader || $isFirstRow) {
382
                if ($isFirstRow) {
383
                    $this->renderRowSeparator(self::SEPARATOR_TOP_BOTTOM);
384
                    $isFirstRow = false;
385
                } else {
386
                    $this->renderRowSeparator(self::SEPARATOR_TOP, $this->headerTitle, $this->style->getHeaderTitleFormat());
387
                }
388
            }
389
            if ($this->horizontal) {
390
                $this->renderRow($row, $this->style->getCellRowFormat(), $this->style->getCellHeaderFormat());
391
            } else {
392
                $this->renderRow($row, $isHeader ? $this->style->getCellHeaderFormat() : $this->style->getCellRowFormat());
393
            }
394
        }
395
        $this->renderRowSeparator(self::SEPARATOR_BOTTOM, $this->footerTitle, $this->style->getFooterTitleFormat());
396
397
        $this->cleanup();
398
        $this->rendered = true;
399
    }
400
401
    /**
402
     * Renders horizontal header separator.
403
     *
404
     * Example:
405
     *
406
     *     +-----+-----------+-------+
407
     */
408
    private function renderRowSeparator(int $type = self::SEPARATOR_MID, string $title = null, string $titleFormat = null)
409
    {
410
        if (0 === $count = $this->numberOfColumns) {
411
            return;
412
        }
413
414
        $borders = $this->style->getBorderChars();
415
        if (!$borders[0] && !$borders[2] && !$this->style->getCrossingChar()) {
416
            return;
417
        }
418
419
        $crossings = $this->style->getCrossingChars();
420
        if (self::SEPARATOR_MID === $type) {
421
            [$horizontal, $leftChar, $midChar, $rightChar] = [$borders[2], $crossings[8], $crossings[0], $crossings[4]];
422
        } elseif (self::SEPARATOR_TOP === $type) {
423
            [$horizontal, $leftChar, $midChar, $rightChar] = [$borders[0], $crossings[1], $crossings[2], $crossings[3]];
424
        } elseif (self::SEPARATOR_TOP_BOTTOM === $type) {
425
            [$horizontal, $leftChar, $midChar, $rightChar] = [$borders[0], $crossings[9], $crossings[10], $crossings[11]];
426
        } else {
427
            [$horizontal, $leftChar, $midChar, $rightChar] = [$borders[0], $crossings[7], $crossings[6], $crossings[5]];
428
        }
429
430
        $markup = $leftChar;
431
        for ($column = 0; $column < $count; ++$column) {
432
            $markup .= str_repeat($horizontal, $this->effectiveColumnWidths[$column]);
433
            $markup .= $column === $count - 1 ? $rightChar : $midChar;
434
        }
435
436
        if (null !== $title) {
437
            $titleLength = Helper::width(Helper::removeDecoration($formatter = $this->output->getFormatter(), $formattedTitle = sprintf($titleFormat, $title)));
0 ignored issues
show
Bug introduced by
It seems like $titleFormat can also be of type null; however, parameter $format of sprintf() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

437
            $titleLength = Helper::width(Helper::removeDecoration($formatter = $this->output->getFormatter(), $formattedTitle = sprintf(/** @scrutinizer ignore-type */ $titleFormat, $title)));
Loading history...
438
            $markupLength = Helper::width($markup);
439
            if ($titleLength > $limit = $markupLength - 4) {
440
                $titleLength = $limit;
441
                $formatLength = Helper::width(Helper::removeDecoration($formatter, sprintf($titleFormat, '')));
442
                $formattedTitle = sprintf($titleFormat, Helper::substr($title, 0, $limit - $formatLength - 3).'...');
443
            }
444
445
            $titleStart = ($markupLength - $titleLength) / 2;
446
            if (false === mb_detect_encoding($markup, null, true)) {
447
                $markup = substr_replace($markup, $formattedTitle, $titleStart, $titleLength);
448
            } else {
449
                $markup = mb_substr($markup, 0, $titleStart).$formattedTitle.mb_substr($markup, $titleStart + $titleLength);
450
            }
451
        }
452
453
        $this->output->writeln(sprintf($this->style->getBorderFormat(), $markup));
0 ignored issues
show
Bug introduced by
It seems like $markup can also be of type array; however, parameter $values of sprintf() does only seem to accept double|integer|string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

453
        $this->output->writeln(sprintf($this->style->getBorderFormat(), /** @scrutinizer ignore-type */ $markup));
Loading history...
454
    }
455
456
    /**
457
     * Renders vertical column separator.
458
     */
459
    private function renderColumnSeparator(int $type = self::BORDER_OUTSIDE): string
460
    {
461
        $borders = $this->style->getBorderChars();
462
463
        return sprintf($this->style->getBorderFormat(), self::BORDER_OUTSIDE === $type ? $borders[1] : $borders[3]);
464
    }
465
466
    /**
467
     * Renders table row.
468
     *
469
     * Example:
470
     *
471
     *     | 9971-5-0210-0 | A Tale of Two Cities  | Charles Dickens  |
472
     */
473
    private function renderRow(array $row, string $cellFormat, string $firstCellFormat = null)
474
    {
475
        $rowContent = $this->renderColumnSeparator(self::BORDER_OUTSIDE);
476
        $columns = $this->getRowColumns($row);
477
        $last = \count($columns) - 1;
478
        foreach ($columns as $i => $column) {
479
            if ($firstCellFormat && 0 === $i) {
480
                $rowContent .= $this->renderCell($row, $column, $firstCellFormat);
481
            } else {
482
                $rowContent .= $this->renderCell($row, $column, $cellFormat);
483
            }
484
            $rowContent .= $this->renderColumnSeparator($last === $i ? self::BORDER_OUTSIDE : self::BORDER_INSIDE);
485
        }
486
        $this->output->writeln($rowContent);
487
    }
488
489
    /**
490
     * Renders table cell with padding.
491
     */
492
    private function renderCell(array $row, int $column, string $cellFormat): string
493
    {
494
        $cell = $row[$column] ?? '';
495
        $width = $this->effectiveColumnWidths[$column];
496
        if ($cell instanceof TableCell && $cell->getColspan() > 1) {
497
            // add the width of the following columns(numbers of colspan).
498
            foreach (range($column + 1, $column + $cell->getColspan() - 1) as $nextColumn) {
499
                $width += $this->getColumnSeparatorWidth() + $this->effectiveColumnWidths[$nextColumn];
500
            }
501
        }
502
503
        // str_pad won't work properly with multi-byte strings, we need to fix the padding
504
        if (false !== $encoding = mb_detect_encoding($cell, null, true)) {
505
            $width += \strlen($cell) - mb_strwidth($cell, $encoding);
506
        }
507
508
        $style = $this->getColumnStyle($column);
509
510
        if ($cell instanceof TableSeparator) {
511
            return sprintf($style->getBorderFormat(), str_repeat($style->getBorderChars()[2], $width));
512
        }
513
514
        $width += Helper::length($cell) - Helper::length(Helper::removeDecoration($this->output->getFormatter(), $cell));
515
        $content = sprintf($style->getCellRowContentFormat(), $cell);
516
517
        $padType = $style->getPadType();
518
        if ($cell instanceof TableCell && $cell->getStyle() instanceof TableCellStyle) {
519
            $isNotStyledByTag = !preg_match('/^<(\w+|(\w+=[\w,]+;?)*)>.+<\/(\w+|(\w+=\w+;?)*)?>$/', $cell);
520
            if ($isNotStyledByTag) {
521
                $cellFormat = $cell->getStyle()->getCellFormat();
522
                if (!\is_string($cellFormat)) {
523
                    $tag = http_build_query($cell->getStyle()->getTagOptions(), '', ';');
524
                    $cellFormat = '<'.$tag.'>%s</>';
525
                }
526
527
                if (strstr($content, '</>')) {
528
                    $content = str_replace('</>', '', $content);
529
                    $width -= 3;
530
                }
531
                if (strstr($content, '<fg=default;bg=default>')) {
532
                    $content = str_replace('<fg=default;bg=default>', '', $content);
533
                    $width -= \strlen('<fg=default;bg=default>');
534
                }
535
            }
536
537
            $padType = $cell->getStyle()->getPadByAlign();
538
        }
539
540
        return sprintf($cellFormat, str_pad($content, $width, $style->getPaddingChar(), $padType));
541
    }
542
543
    /**
544
     * Calculate number of columns for this table.
545
     */
546
    private function calculateNumberOfColumns(array $rows)
547
    {
548
        $columns = [0];
549
        foreach ($rows as $row) {
550
            if ($row instanceof TableSeparator) {
551
                continue;
552
            }
553
554
            $columns[] = $this->getNumberOfColumns($row);
555
        }
556
557
        $this->numberOfColumns = max($columns);
558
    }
559
560
    private function buildTableRows(array $rows): TableRows
561
    {
562
        /** @var WrappableOutputFormatterInterface $formatter */
563
        $formatter = $this->output->getFormatter();
564
        $unmergedRows = [];
565
        for ($rowKey = 0; $rowKey < \count($rows); ++$rowKey) {
0 ignored issues
show
Performance Best Practice introduced by
It seems like you are calling the size function count() as part of the test condition. You might want to compute the size beforehand, and not on each iteration.

If the size of the collection does not change during the iteration, it is generally a good practice to compute it beforehand, and not on each iteration:

for ($i=0; $i<count($array); $i++) { // calls count() on each iteration
}

// Better
for ($i=0, $c=count($array); $i<$c; $i++) { // calls count() just once
}
Loading history...
566
            $rows = $this->fillNextRows($rows, $rowKey);
567
568
            // Remove any new line breaks and replace it with a new line
569
            foreach ($rows[$rowKey] as $column => $cell) {
570
                $colspan = $cell instanceof TableCell ? $cell->getColspan() : 1;
571
572
                if (isset($this->columnMaxWidths[$column]) && Helper::width(Helper::removeDecoration($formatter, $cell)) > $this->columnMaxWidths[$column]) {
573
                    $cell = $formatter->formatAndWrap($cell, $this->columnMaxWidths[$column] * $colspan);
574
                }
575
                if (!strstr($cell ?? '', "\n")) {
576
                    continue;
577
                }
578
                $escaped = implode("\n", array_map([OutputFormatter::class, 'escapeTrailingBackslash'], explode("\n", $cell)));
579
                $cell = $cell instanceof TableCell ? new TableCell($escaped, ['colspan' => $cell->getColspan()]) : $escaped;
580
                $lines = explode("\n", str_replace("\n", "<fg=default;bg=default>\n</>", $cell));
581
                foreach ($lines as $lineKey => $line) {
582
                    if ($colspan > 1) {
583
                        $line = new TableCell($line, ['colspan' => $colspan]);
584
                    }
585
                    if (0 === $lineKey) {
586
                        $rows[$rowKey][$column] = $line;
587
                    } else {
588
                        if (!\array_key_exists($rowKey, $unmergedRows) || !\array_key_exists($lineKey, $unmergedRows[$rowKey])) {
589
                            $unmergedRows[$rowKey][$lineKey] = $this->copyRow($rows, $rowKey);
590
                        }
591
                        $unmergedRows[$rowKey][$lineKey][$column] = $line;
592
                    }
593
                }
594
            }
595
        }
596
597
        return new TableRows(function () use ($rows, $unmergedRows): \Traversable {
598
            foreach ($rows as $rowKey => $row) {
599
                yield $this->fillCells($row);
600
601
                if (isset($unmergedRows[$rowKey])) {
602
                    foreach ($unmergedRows[$rowKey] as $unmergedRow) {
603
                        yield $this->fillCells($unmergedRow);
604
                    }
605
                }
606
            }
607
        });
608
    }
609
610
    private function calculateRowCount(): int
611
    {
612
        $numberOfRows = \count(iterator_to_array($this->buildTableRows(array_merge($this->headers, [new TableSeparator()], $this->rows))));
613
614
        if ($this->headers) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->headers of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
615
            ++$numberOfRows; // Add row for header separator
616
        }
617
618
        if (\count($this->rows) > 0) {
619
            ++$numberOfRows; // Add row for footer separator
620
        }
621
622
        return $numberOfRows;
623
    }
624
625
    /**
626
     * fill rows that contains rowspan > 1.
627
     *
628
     * @throws InvalidArgumentException
629
     */
630
    private function fillNextRows(array $rows, int $line): array
631
    {
632
        $unmergedRows = [];
633
        foreach ($rows[$line] as $column => $cell) {
634
            if (null !== $cell && !$cell instanceof TableCell && !is_scalar($cell) && !(\is_object($cell) && method_exists($cell, '__toString'))) {
635
                throw new InvalidArgumentException(sprintf('A cell must be a TableCell, a scalar or an object implementing "__toString()", "%s" given.', get_debug_type($cell)));
636
            }
637
            if ($cell instanceof TableCell && $cell->getRowspan() > 1) {
638
                $nbLines = $cell->getRowspan() - 1;
639
                $lines = [$cell];
640
                if (strstr($cell, "\n")) {
641
                    $lines = explode("\n", str_replace("\n", "<fg=default;bg=default>\n</>", $cell));
642
                    $nbLines = \count($lines) > $nbLines ? substr_count($cell, "\n") : $nbLines;
643
644
                    $rows[$line][$column] = new TableCell($lines[0], ['colspan' => $cell->getColspan(), 'style' => $cell->getStyle()]);
645
                    unset($lines[0]);
646
                }
647
648
                // create a two dimensional array (rowspan x colspan)
649
                $unmergedRows = array_replace_recursive(array_fill($line + 1, $nbLines, []), $unmergedRows);
650
                foreach ($unmergedRows as $unmergedRowKey => $unmergedRow) {
651
                    $value = $lines[$unmergedRowKey - $line] ?? '';
652
                    $unmergedRows[$unmergedRowKey][$column] = new TableCell($value, ['colspan' => $cell->getColspan(), 'style' => $cell->getStyle()]);
653
                    if ($nbLines === $unmergedRowKey - $line) {
654
                        break;
655
                    }
656
                }
657
            }
658
        }
659
660
        foreach ($unmergedRows as $unmergedRowKey => $unmergedRow) {
661
            // we need to know if $unmergedRow will be merged or inserted into $rows
662
            if (isset($rows[$unmergedRowKey]) && \is_array($rows[$unmergedRowKey]) && ($this->getNumberOfColumns($rows[$unmergedRowKey]) + $this->getNumberOfColumns($unmergedRows[$unmergedRowKey]) <= $this->numberOfColumns)) {
663
                foreach ($unmergedRow as $cellKey => $cell) {
664
                    // insert cell into row at cellKey position
665
                    array_splice($rows[$unmergedRowKey], $cellKey, 0, [$cell]);
666
                }
667
            } else {
668
                $row = $this->copyRow($rows, $unmergedRowKey - 1);
669
                foreach ($unmergedRow as $column => $cell) {
670
                    if (!empty($cell)) {
671
                        $row[$column] = $unmergedRow[$column];
672
                    }
673
                }
674
                array_splice($rows, $unmergedRowKey, 0, [$row]);
675
            }
676
        }
677
678
        return $rows;
679
    }
680
681
    /**
682
     * fill cells for a row that contains colspan > 1.
683
     */
684
    private function fillCells($row)
685
    {
686
        $newRow = [];
687
688
        foreach ($row as $column => $cell) {
689
            $newRow[] = $cell;
690
            if ($cell instanceof TableCell && $cell->getColspan() > 1) {
691
                foreach (range($column + 1, $column + $cell->getColspan() - 1) as $position) {
692
                    // insert empty value at column position
693
                    $newRow[] = '';
694
                }
695
            }
696
        }
697
698
        return $newRow ?: $row;
699
    }
700
701
    private function copyRow(array $rows, int $line): array
702
    {
703
        $row = $rows[$line];
704
        foreach ($row as $cellKey => $cellValue) {
705
            $row[$cellKey] = '';
706
            if ($cellValue instanceof TableCell) {
707
                $row[$cellKey] = new TableCell('', ['colspan' => $cellValue->getColspan()]);
708
            }
709
        }
710
711
        return $row;
712
    }
713
714
    /**
715
     * Gets number of columns by row.
716
     */
717
    private function getNumberOfColumns(array $row): int
718
    {
719
        $columns = \count($row);
720
        foreach ($row as $column) {
721
            $columns += $column instanceof TableCell ? ($column->getColspan() - 1) : 0;
722
        }
723
724
        return $columns;
725
    }
726
727
    /**
728
     * Gets list of columns for the given row.
729
     */
730
    private function getRowColumns(array $row): array
731
    {
732
        $columns = range(0, $this->numberOfColumns - 1);
733
        foreach ($row as $cellKey => $cell) {
734
            if ($cell instanceof TableCell && $cell->getColspan() > 1) {
735
                // exclude grouped columns.
736
                $columns = array_diff($columns, range($cellKey + 1, $cellKey + $cell->getColspan() - 1));
737
            }
738
        }
739
740
        return $columns;
741
    }
742
743
    /**
744
     * Calculates columns widths.
745
     */
746
    private function calculateColumnsWidth(iterable $rows)
747
    {
748
        for ($column = 0; $column < $this->numberOfColumns; ++$column) {
749
            $lengths = [];
750
            foreach ($rows as $row) {
751
                if ($row instanceof TableSeparator) {
752
                    continue;
753
                }
754
755
                foreach ($row as $i => $cell) {
756
                    if ($cell instanceof TableCell) {
757
                        $textContent = Helper::removeDecoration($this->output->getFormatter(), $cell);
758
                        $textLength = Helper::width($textContent);
759
                        if ($textLength > 0) {
760
                            $contentColumns = str_split($textContent, ceil($textLength / $cell->getColspan()));
0 ignored issues
show
Bug introduced by
ceil($textLength / $cell->getColspan()) of type double is incompatible with the type integer expected by parameter $length of str_split(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

760
                            $contentColumns = str_split($textContent, /** @scrutinizer ignore-type */ ceil($textLength / $cell->getColspan()));
Loading history...
761
                            foreach ($contentColumns as $position => $content) {
762
                                $row[$i + $position] = $content;
763
                            }
764
                        }
765
                    }
766
                }
767
768
                $lengths[] = $this->getCellWidth($row, $column);
769
            }
770
771
            $this->effectiveColumnWidths[$column] = max($lengths) + Helper::width($this->style->getCellRowContentFormat()) - 2;
772
        }
773
    }
774
775
    private function getColumnSeparatorWidth(): int
776
    {
777
        return Helper::width(sprintf($this->style->getBorderFormat(), $this->style->getBorderChars()[3]));
778
    }
779
780
    private function getCellWidth(array $row, int $column): int
781
    {
782
        $cellWidth = 0;
783
784
        if (isset($row[$column])) {
785
            $cell = $row[$column];
786
            $cellWidth = Helper::width(Helper::removeDecoration($this->output->getFormatter(), $cell));
787
        }
788
789
        $columnWidth = $this->columnWidths[$column] ?? 0;
790
        $cellWidth = max($cellWidth, $columnWidth);
791
792
        return isset($this->columnMaxWidths[$column]) ? min($this->columnMaxWidths[$column], $cellWidth) : $cellWidth;
793
    }
794
795
    /**
796
     * Called after rendering to cleanup cache data.
797
     */
798
    private function cleanup()
799
    {
800
        $this->effectiveColumnWidths = [];
801
        $this->numberOfColumns = null;
802
    }
803
804
    private static function initStyles(): array
805
    {
806
        $borderless = new TableStyle();
807
        $borderless
808
            ->setHorizontalBorderChars('=')
809
            ->setVerticalBorderChars(' ')
810
            ->setDefaultCrossingChar(' ')
811
        ;
812
813
        $compact = new TableStyle();
814
        $compact
815
            ->setHorizontalBorderChars('')
816
            ->setVerticalBorderChars(' ')
817
            ->setDefaultCrossingChar('')
818
            ->setCellRowContentFormat('%s')
819
        ;
820
821
        $styleGuide = new TableStyle();
822
        $styleGuide
823
            ->setHorizontalBorderChars('-')
824
            ->setVerticalBorderChars(' ')
825
            ->setDefaultCrossingChar(' ')
826
            ->setCellHeaderFormat('%s')
827
        ;
828
829
        $box = (new TableStyle())
830
            ->setHorizontalBorderChars('─')
831
            ->setVerticalBorderChars('│')
832
            ->setCrossingChars('┼', '┌', '┬', '┐', '┤', '┘', '┴', '└', '├')
833
        ;
834
835
        $boxDouble = (new TableStyle())
836
            ->setHorizontalBorderChars('═', '─')
837
            ->setVerticalBorderChars('║', '│')
838
            ->setCrossingChars('┼', '╔', '╤', '╗', '╢', '╝', '╧', '╚', '╟', '╠', '╪', '╣')
839
        ;
840
841
        return [
842
            'default' => new TableStyle(),
843
            'borderless' => $borderless,
844
            'compact' => $compact,
845
            'symfony-style-guide' => $styleGuide,
846
            'box' => $box,
847
            'box-double' => $boxDouble,
848
        ];
849
    }
850
851
    private function resolveStyle($name): TableStyle
852
    {
853
        if ($name instanceof TableStyle) {
854
            return $name;
855
        }
856
857
        if (isset(self::$styles[$name])) {
858
            return self::$styles[$name];
859
        }
860
861
        throw new InvalidArgumentException(sprintf('Style "%s" is not defined.', $name));
862
    }
863
}
864