From ed18767fbebb59b807d222eaee5064b5240c2471 Mon Sep 17 00:00:00 2001 From: Abdellatif Ait boudad Date: Sat, 17 Jan 2015 23:18:28 +0000 Subject: [PATCH] [Console][Table] Add support for colspan/rowspan + multiple header lines --- .../Component/Console/Helper/Table.php | 276 +++++++++++++++--- .../Component/Console/Helper/TableCell.php | 77 +++++ .../Console/Helper/TableSeparator.php | 10 +- .../Console/Tests/Helper/TableTest.php | 209 +++++++++++++ 4 files changed, 526 insertions(+), 46 deletions(-) create mode 100644 src/Symfony/Component/Console/Helper/TableCell.php diff --git a/src/Symfony/Component/Console/Helper/Table.php b/src/Symfony/Component/Console/Helper/Table.php index 67cfbb503d..051150b822 100644 --- a/src/Symfony/Component/Console/Helper/Table.php +++ b/src/Symfony/Component/Console/Helper/Table.php @@ -18,6 +18,7 @@ use Symfony\Component\Console\Output\OutputInterface; * * @author Fabien Potencier * @author Саша Стаменковић + * @author Abdellatif Ait boudad */ 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; } /** diff --git a/src/Symfony/Component/Console/Helper/TableCell.php b/src/Symfony/Component/Console/Helper/TableCell.php new file mode 100644 index 0000000000..aa0d318079 --- /dev/null +++ b/src/Symfony/Component/Console/Helper/TableCell.php @@ -0,0 +1,77 @@ + + * + * 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 + */ +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']; + } +} diff --git a/src/Symfony/Component/Console/Helper/TableSeparator.php b/src/Symfony/Component/Console/Helper/TableSeparator.php index 1f6981b038..8cbbc6613b 100644 --- a/src/Symfony/Component/Console/Helper/TableSeparator.php +++ b/src/Symfony/Component/Console/Helper/TableSeparator.php @@ -16,6 +16,14 @@ namespace Symfony\Component\Console\Helper; * * @author Fabien Potencier */ -class TableSeparator +class TableSeparator extends TableCell { + /** + * @param string $value + * @param array $options + */ + public function __construct(array $options = array()) + { + parent::__construct('', $options); + } } diff --git a/src/Symfony/Component/Console/Tests/Helper/TableTest.php b/src/Symfony/Component/Console/Tests/Helper/TableTest.php index 18a2ab6bcf..3db51f45b0 100644 --- a/src/Symfony/Component/Console/Tests/Helper/TableTest.php +++ b/src/Symfony/Component/Console/Tests/Helper/TableTest.php @@ -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', +<< 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', +<<
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', +<<
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', +<<
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', +<<
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', +<<
array( + array( + array(new TableCell('Main title', array('colspan' => 3))), + array('ISBN', 'Title', 'Author'), + ), + array(), + 'default', +<<