feature #13438 [Console][Table] Add support for colspan/rowspan + multiple header lines (aitboudad)

This PR was squashed before being merged into the 2.7 branch (closes #13438).

Discussion
----------

[Console][Table] Add support for colspan/rowspan + multiple header lines

| Q             | A
| ------------- | ---
| Bug fix?      | no
| New feature?  | yes
| BC breaks?    | no
| Deprecations? | no
| Fixed tickets  | #13368, #13369
| Tests pass?   | yes
| License       | MIT

This PR introduce a new feature described in #13368 and #13369,
I created a new class ```TableCell``` which can allow us to specify colspan/rowspan for each cell.
```php
$table->addRow([new TableCell("data", array('rowspan' => 1, 'colspan' => 2)), 'data']);
```

- [x] #13368 Add support for colspan/rowspan
- [x] #13369 Add support for multiple header lines
- [ ] add doc

Commits
-------

ed18767 [Console][Table] Add support for colspan/rowspan + multiple header lines
This commit is contained in:
Fabien Potencier 2015-03-26 17:59:59 +01:00
commit 350f30b0e1
4 changed files with 526 additions and 46 deletions

View File

@ -18,6 +18,7 @@ use Symfony\Component\Console\Output\OutputInterface;
*
* @author Fabien Potencier <fabien@symfony.com>
* @author Саша Стаменковић <umpirsky@gmail.com>
* @author Abdellatif Ait boudad <a.aitboudad@gmail.com>
*/
class Table
{
@ -139,7 +140,12 @@ class Table
public function setHeaders(array $headers)
{
$this->headers = array_values($headers);
$headers = array_values($headers);
if (!empty($headers) && !is_array($headers[0])) {
$headers = array($headers);
}
$this->headers = $headers;
return $this;
}
@ -174,30 +180,6 @@ class Table
$this->rows[] = array_values($row);
end($this->rows);
$rowKey = key($this->rows);
reset($this->rows);
foreach ($row as $key => $cellValue) {
if (!strstr($cellValue, "\n")) {
continue;
}
$lines = explode("\n", $cellValue);
$this->rows[$rowKey][$key] = $lines[0];
unset($lines[0]);
foreach ($lines as $lineKey => $line) {
$nextRowKey = $rowKey + $lineKey + 1;
if (isset($this->rows[$nextRowKey])) {
$this->rows[$nextRowKey][$key] = $line;
} else {
$this->rows[$nextRowKey] = array($key => $line);
}
}
}
return $this;
}
@ -222,10 +204,16 @@ class Table
*/
public function render()
{
$this->calculateNumberOfColumns();
$this->rows = $this->buildTableRows($this->rows);
$this->headers = $this->buildTableRows($this->headers);
$this->renderRowSeparator();
$this->renderRow($this->headers, $this->style->getCellHeaderFormat());
if (!empty($this->headers)) {
$this->renderRowSeparator();
foreach ($this->headers as $header) {
$this->renderRow($header, $this->style->getCellHeaderFormat());
$this->renderRowSeparator();
}
}
foreach ($this->rows as $row) {
if ($row instanceof TableSeparator) {
@ -248,7 +236,7 @@ class Table
*/
private function renderRowSeparator()
{
if (0 === $count = $this->getNumberOfColumns()) {
if (0 === $count = $this->numberOfColumns) {
return;
}
@ -287,7 +275,7 @@ class Table
}
$this->renderColumnSeparator();
for ($column = 0, $count = $this->getNumberOfColumns(); $column < $count; $column++) {
foreach ($this->getRowColumns($row) as $column) {
$this->renderCell($row, $column, $cellFormat);
$this->renderColumnSeparator();
}
@ -305,38 +293,215 @@ class Table
{
$cell = isset($row[$column]) ? $row[$column] : '';
$width = $this->getColumnWidth($column);
if ($cell instanceof TableCell && $cell->getColspan() > 1) {
// add the width of the following columns(numbers of colspan).
foreach (range($column + 1, $column + $cell->getColspan() - 1) as $nextColumn) {
$width += $this->getColumnSeparatorWidth() + $this->getColumnWidth($nextColumn);
}
}
// str_pad won't work properly with multi-byte strings, we need to fix the padding
if (function_exists('mb_strwidth') && false !== $encoding = mb_detect_encoding($cell)) {
$width += strlen($cell) - mb_strwidth($cell, $encoding);
}
$width += Helper::strlen($cell) - Helper::strlenWithoutDecoration($this->output->getFormatter(), $cell);
$content = sprintf($this->style->getCellRowContentFormat(), $cell);
$this->output->write(sprintf($cellFormat, str_pad($content, $width, $this->style->getPaddingChar(), $this->style->getPadType())));
if ($cell instanceof TableSeparator) {
$this->output->write(sprintf($this->style->getBorderFormat(), str_repeat($this->style->getHorizontalBorderChar(), $width)));
} else {
$width += Helper::strlen($cell) - Helper::strlenWithoutDecoration($this->output->getFormatter(), $cell);
$content = sprintf($this->style->getCellRowContentFormat(), $cell);
$this->output->write(sprintf($cellFormat, str_pad($content, $width, $this->style->getPaddingChar(), $this->style->getPadType())));
}
}
/**
* Gets number of columns for this table.
*
* @return int
* Calculate number of columns for this table.
*/
private function getNumberOfColumns()
private function calculateNumberOfColumns()
{
if (null !== $this->numberOfColumns) {
return $this->numberOfColumns;
return;
}
$columns = array(count($this->headers));
foreach ($this->rows as $row) {
$columns[] = count($row);
$columns = array(0);
foreach (array_merge($this->headers, $this->rows) as $row) {
if ($row instanceof TableSeparator) {
continue;
}
$columns[] = $this->getNumberOfColumns($row);
}
return $this->numberOfColumns = max($columns);
}
private function buildTableRows($rows)
{
$unmergedRows = array();
for ($rowKey = 0; $rowKey < count($rows); $rowKey++) {
$rows = $this->fillNextRows($rows, $rowKey);
// Remove any new line breaks and replace it with a new line
foreach ($rows[$rowKey] as $column => $cell) {
$rows[$rowKey] = $this->fillCells($rows[$rowKey], $column);
if (!strstr($cell, "\n")) {
continue;
}
$lines = explode("\n", $cell);
foreach ($lines as $lineKey => $line) {
if ($cell instanceof TableCell) {
$line = new TableCell($line, array('colspan' => $cell->getColspan()));
}
if (0 === $lineKey) {
$rows[$rowKey][$column] = $line;
} else {
$unmergedRows[$rowKey][$lineKey][$column] = $line;
}
}
}
}
$tableRows = array();
foreach ($rows as $rowKey => $row) {
$tableRows[] = $row;
if (isset($unmergedRows[$rowKey])) {
$tableRows = array_merge($tableRows, $unmergedRows[$rowKey]);
}
}
return $tableRows;
}
/**
* fill rows that contains rowspan > 1.
*
* @param array $rows
* @param array $line
*
* @return array
*/
private function fillNextRows($rows, $line)
{
$unmergedRows = array();
foreach ($rows[$line] as $column => $cell) {
if ($cell instanceof TableCell && $cell->getRowspan() > 1) {
$nbLines = $cell->getRowspan()-1;
$lines = array($cell);
if (strstr($cell, "\n")) {
$lines = explode("\n", $cell);
$nbLines = count($lines) > $nbLines ? substr_count($cell, "\n") : $nbLines;
$rows[$line][$column] = new TableCell($lines[0], array('colspan' => $cell->getColspan()));
unset($lines[0]);
}
// create a two dimensional array (rowspan x colspan)
$unmergedRows = array_replace_recursive(array_fill($line + 1, $nbLines, ''), $unmergedRows);
foreach ($unmergedRows as $unmergedRowKey => $unmergedRow) {
$value = isset($lines[$unmergedRowKey - $line]) ? $lines[$unmergedRowKey - $line] : '';
$unmergedRows[$unmergedRowKey][$column] = new TableCell($value, array('colspan' => $cell->getColspan()));
}
}
}
foreach ($unmergedRows as $unmergedRowKey => $unmergedRow) {
// we need to know if $unmergedRow will be merged or inserted into $rows
if (isset($rows[$unmergedRowKey]) && is_array($rows[$unmergedRowKey]) && ($this->getNumberOfColumns($rows[$unmergedRowKey]) + $this->getNumberOfColumns($unmergedRows[$unmergedRowKey]) <= $this->numberOfColumns)) {
foreach ($unmergedRow as $cellKey => $cell) {
// insert cell into row at cellKey position
array_splice($rows[$unmergedRowKey], $cellKey, 0, array($cell));
}
} else {
$row = $this->copyRow($rows, $unmergedRowKey-1);
foreach ($unmergedRow as $column => $cell) {
if (!empty($cell)) {
$row[$column] = $unmergedRow[$column];
}
}
array_splice($rows, $unmergedRowKey, 0, array($row));
}
}
return $rows;
}
/**
* fill cells for a row that contains colspan > 1.
*
* @param array $row
* @param array $column
*
* @return array
*/
private function fillCells($row, $column)
{
$cell = $row[$column];
if ($cell instanceof TableCell && $cell->getColspan() > 1) {
foreach (range($column + 1, $column + $cell->getColspan() - 1) as $position) {
// insert empty value into rows at column position
array_splice($row, $position, 0, '');
}
}
return $row;
}
/**
* @param array $rows
* @param int $line
*
* @return array
*/
private function copyRow($rows, $line)
{
$row = $rows[$line];
foreach ($row as $cellKey => $cellValue) {
$row[$cellKey] = '';
if ($cellValue instanceof TableCell) {
$row[$cellKey] = new TableCell('', array('colspan' => $cellValue->getColspan()));
}
}
return $row;
}
/**
* Gets number of columns by row.
*
* @param array $row
*
* @return int
*/
private function getNumberOfColumns(array $row)
{
$columns = count($row);
foreach ($row as $column) {
$columns += $column instanceof TableCell ? ($column->getColspan()-1) : 0;
}
return $columns;
}
/**
* Gets list of columns for the given row.
*
* @param array $row
*
* @return array()
*/
private function getRowColumns($row)
{
$columns = range(0, $this->numberOfColumns-1);
foreach ($row as $cellKey => $cell) {
if ($cell instanceof TableCell && $cell->getColspan() > 1) {
// exclude grouped columns.
$columns = array_diff($columns, range($cellKey+1, $cellKey + $cell->getColspan()-1));
}
}
return $columns;
}
/**
* Gets column width.
*
@ -350,8 +515,7 @@ class Table
return $this->columnWidths[$column];
}
$lengths = array($this->getCellWidth($this->headers, $column));
foreach ($this->rows as $row) {
foreach (array_merge($this->headers, $this->rows) as $row) {
if ($row instanceof TableSeparator) {
continue;
}
@ -362,6 +526,18 @@ class Table
return $this->columnWidths[$column] = max($lengths) + strlen($this->style->getCellRowContentFormat()) - 2;
}
/**
* Gets column width.
*
* @param int $column
*
* @return int
*/
private function getColumnSeparatorWidth()
{
return strlen(sprintf($this->style->getBorderFormat(), $this->style->getVerticalBorderChar()));
}
/**
* Gets cell width.
*
@ -372,7 +548,17 @@ class Table
*/
private function getCellWidth(array $row, $column)
{
return isset($row[$column]) ? Helper::strlenWithoutDecoration($this->output->getFormatter(), $row[$column]) : 0;
if (isset($row[$column])) {
$cell = $row[$column];
if ($cell instanceof TableCell && $cell->getColspan() > 1) {
// we assume that cell value will be across more than one column.
$cell = substr($cell, 0, strlen($cell)/$cell->getColspan());
}
return Helper::strlenWithoutDecoration($this->output->getFormatter(), $cell);
}
return 0;
}
/**

View File

@ -0,0 +1,77 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Console\Helper;
/**
* @author Abdellatif Ait boudad <a.aitboudad@gmail.com>
*/
class TableCell
{
/**
* @var string
*/
private $value;
/**
* @var array
*/
private $options = array(
'rowspan' => 1,
'colspan' => 1,
);
/**
* @param string $value
* @param array $options
*/
public function __construct($value = '', array $options = array())
{
$this->value = $value;
// check option names
if ($diff = array_diff(array_keys($options), array_keys($this->options))) {
throw new \InvalidArgumentException(sprintf('The TableCell does not support the following options: \'%s\'.', implode('\', \'', $diff)));
}
$this->options = array_merge($this->options, $options);
}
/**
* Returns the cell value.
*
* @return string
*/
public function __toString()
{
return $this->value;
}
/**
* Gets number of colspan.
*
* @return int
*/
public function getColspan()
{
return (int) $this->options['colspan'];
}
/**
* Gets number of rowspan.
*
* @return int
*/
public function getRowspan()
{
return (int) $this->options['rowspan'];
}
}

View File

@ -16,6 +16,14 @@ namespace Symfony\Component\Console\Helper;
*
* @author Fabien Potencier <fabien@symfony.com>
*/
class TableSeparator
class TableSeparator extends TableCell
{
/**
* @param string $value
* @param array $options
*/
public function __construct(array $options = array())
{
parent::__construct('', $options);
}
}

View File

@ -14,6 +14,7 @@ namespace Symfony\Component\Console\Tests\Helper;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Helper\TableStyle;
use Symfony\Component\Console\Helper\TableSeparator;
use Symfony\Component\Console\Helper\TableCell;
use Symfony\Component\Console\Output\StreamOutput;
class TableTest extends \PHPUnit_Framework_TestCase
@ -250,6 +251,214 @@ TABLE
| 9971-5-0210-0 | A Tale of Two Cities | Charles Dickens |
+----------------------------------+----------------------+-----------------+
TABLE
),
'Cell with colspan' => array(
array('ISBN', 'Title', 'Author'),
array(
array('99921-58-10-7', 'Divine Comedy', 'Dante Alighieri'),
new TableSeparator(),
array(new TableCell('Divine Comedy(Dante Alighieri)', array('colspan' => 3))),
new TableSeparator(),
array(
new TableCell('Arduino: A Quick-Start Guide', array('colspan' => 2)),
'Mark Schmidt',
),
new TableSeparator(),
array(
'9971-5-0210-0',
new TableCell("A Tale of \nTwo Cities", array('colspan' => 2)),
),
),
'default',
<<<TABLE
+----------------+---------------+-----------------+
| ISBN | Title | Author |
+----------------+---------------+-----------------+
| 99921-58-10-7 | Divine Comedy | Dante Alighieri |
+----------------+---------------+-----------------+
| Divine Comedy(Dante Alighieri) |
+----------------+---------------+-----------------+
| Arduino: A Quick-Start Guide | Mark Schmidt |
+----------------+---------------+-----------------+
| 9971-5-0210-0 | A Tale of |
| | Two Cities |
+----------------+---------------+-----------------+
TABLE
),
'Cell with rowspan' => array(
array('ISBN', 'Title', 'Author'),
array(
array(
new TableCell('9971-5-0210-0', array('rowspan' => 3)),
'Divine Comedy',
'Dante Alighieri',
),
array('A Tale of Two Cities', 'Charles Dickens'),
array("The Lord of \nthe Rings", "J. R. \nR. Tolkien"),
new TableSeparator(),
array('80-902734-1-6', new TableCell("And Then \nThere \nWere None", array('rowspan' => 3)), 'Agatha Christie'),
array('80-902734-1-7', 'Test'),
),
'default',
<<<TABLE
+---------------+----------------------+-----------------+
| ISBN | Title | Author |
+---------------+----------------------+-----------------+
| 9971-5-0210-0 | Divine Comedy | Dante Alighieri |
| | A Tale of Two Cities | Charles Dickens |
| | The Lord of | J. R. |
| | the Rings | R. Tolkien |
+---------------+----------------------+-----------------+
| 80-902734-1-6 | And Then | Agatha Christie |
| 80-902734-1-7 | There | Test |
| | Were None | |
+---------------+----------------------+-----------------+
TABLE
),
'Cell with rowspan and colspan' => array(
array('ISBN', 'Title', 'Author'),
array(
array(
new TableCell('9971-5-0210-0', array('rowspan' => 2, 'colspan' => 2)),
'Dante Alighieri',
),
array('Charles Dickens'),
new TableSeparator(),
array(
'Dante Alighieri',
new TableCell('9971-5-0210-0', array('rowspan' => 3, 'colspan' => 2)),
),
array('J. R. R. Tolkien'),
array('J. R. R'),
),
'default',
<<<TABLE
+------------------+--------+-----------------+
| ISBN | Title | Author |
+------------------+--------+-----------------+
| 9971-5-0210-0 | Dante Alighieri |
| | Charles Dickens |
+------------------+--------+-----------------+
| Dante Alighieri | 9971-5-0210-0 |
| J. R. R. Tolkien | |
| J. R. R | |
+------------------+--------+-----------------+
TABLE
),
'Cell with rowspan and colspan contains new line break' => array(
array('ISBN', 'Title', 'Author'),
array(
array(
new TableCell("9971\n-5-\n021\n0-0", array('rowspan' => 2, 'colspan' => 2)),
'Dante Alighieri',
),
array('Charles Dickens'),
new TableSeparator(),
array(
'Dante Alighieri',
new TableCell("9971\n-5-\n021\n0-0", array('rowspan' => 2, 'colspan' => 2)),
),
array('Charles Dickens'),
new TableSeparator(),
array(
new TableCell("9971\n-5-\n021\n0-0", array('rowspan' => 2, 'colspan' => 2)),
new TableCell("Dante \nAlighieri", array('rowspan' => 2, 'colspan' => 1)),
),
),
'default',
<<<TABLE
+-----------------+-------+-----------------+
| ISBN | Title | Author |
+-----------------+-------+-----------------+
| 9971 | Dante Alighieri |
| -5- | Charles Dickens |
| 021 | |
| 0-0 | |
+-----------------+-------+-----------------+
| Dante Alighieri | 9971 |
| Charles Dickens | -5- |
| | 021 |
| | 0-0 |
+-----------------+-------+-----------------+
| 9971 | Dante |
| -5- | Alighieri |
| 021 | |
| 0-0 | |
+-----------------+-------+-----------------+
TABLE
),
'Cell with rowspan and colspan without using TableSeparator' => array(
array('ISBN', 'Title', 'Author'),
array(
array(
new TableCell("9971\n-5-\n021\n0-0", array('rowspan' => 2, 'colspan' => 2)),
'Dante Alighieri',
),
array('Charles Dickens'),
array(
'Dante Alighieri',
new TableCell("9971\n-5-\n021\n0-0", array('rowspan' => 2, 'colspan' => 2)),
),
array('Charles Dickens'),
),
'default',
<<<TABLE
+-----------------+-------+-----------------+
| ISBN | Title | Author |
+-----------------+-------+-----------------+
| 9971 | Dante Alighieri |
| -5- | Charles Dickens |
| 021 | |
| 0-0 | |
| Dante Alighieri | 9971 |
| Charles Dickens | -5- |
| | 021 |
| | 0-0 |
+-----------------+-------+-----------------+
TABLE
),
'Cell with rowspan and colspan with separator inside a rowspan' => array(
array('ISBN', 'Author'),
array(
array(
new TableCell("9971-5-0210-0", array('rowspan' => 3, 'colspan' => 1)),
'Dante Alighieri',
),
array(new TableSeparator()),
array('Charles Dickens'),
),
'default',
<<<TABLE
+---------------+-----------------+
| ISBN | Author |
+---------------+-----------------+
| 9971-5-0210-0 | Dante Alighieri |
| |-----------------|
| | Charles Dickens |
+---------------+-----------------+
TABLE
),
'Multiple header lines' => array(
array(
array(new TableCell('Main title', array('colspan' => 3))),
array('ISBN', 'Title', 'Author'),
),
array(),
'default',
<<<TABLE
+------+-------+--------+
| Main title |
+------+-------+--------+
| ISBN | Title | Author |
+------+-------+--------+
TABLE
),
);