From e5c57d679457ed0c088439f8f9d1e9ee306d5df1 Mon Sep 17 00:00:00 2001 From: Ingmar Runge Date: Fri, 27 May 2016 09:42:14 +0200 Subject: [PATCH] Preservation of row indices, empty rows can be read. --- src/Spout/Reader/AbstractReader.php | 26 +++- src/Spout/Reader/ODS/Reader.php | 2 +- src/Spout/Reader/ODS/RowIterator.php | 109 ++++++++++----- src/Spout/Reader/ODS/Sheet.php | 7 +- src/Spout/Reader/ODS/SheetIterator.php | 13 +- src/Spout/Reader/ReaderOptions.php | 62 +++++++++ src/Spout/Reader/XLSX/Helper/SheetHelper.php | 13 +- src/Spout/Reader/XLSX/Reader.php | 2 +- src/Spout/Reader/XLSX/RowIterator.php | 56 +++++--- src/Spout/Reader/XLSX/Sheet.php | 7 +- src/Spout/Reader/XLSX/SheetIterator.php | 7 +- tests/Spout/Reader/ODS/ReaderTest.php | 131 ++++++++++++++++-- tests/Spout/Reader/XLSX/ReaderTest.php | 94 +++++++++++-- .../ods/sheet_with_consecutive_empty_rows.ods | Bin 0 -> 7989 bytes .../ods/sheet_with_repeated_rows.ods | Bin 0 -> 2884 bytes .../sheet_with_consecutive_empty_rows.xlsx | Bin 0 -> 8302 bytes 16 files changed, 428 insertions(+), 101 deletions(-) create mode 100644 src/Spout/Reader/ReaderOptions.php create mode 100644 tests/resources/ods/sheet_with_consecutive_empty_rows.ods create mode 100644 tests/resources/ods/sheet_with_repeated_rows.ods create mode 100644 tests/resources/xlsx/sheet_with_consecutive_empty_rows.xlsx diff --git a/src/Spout/Reader/AbstractReader.php b/src/Spout/Reader/AbstractReader.php index cb476ab..9089828 100644 --- a/src/Spout/Reader/AbstractReader.php +++ b/src/Spout/Reader/AbstractReader.php @@ -19,8 +19,16 @@ abstract class AbstractReader implements ReaderInterface /** @var \Box\Spout\Common\Helper\GlobalFunctionsHelper Helper to work with global functions */ protected $globalFunctionsHelper; - /** @var bool Whether date/time values should be returned as PHP objects or be formatted as strings */ - protected $shouldFormatDates = false; + /** @var \Box\Spout\Reader\ReaderOptions */ + protected $readerOptions; + + /** + * The constructor. + */ + public function __construct() + { + $this->readerOptions = new ReaderOptions(); + } /** * Returns whether stream wrappers are supported @@ -69,7 +77,19 @@ abstract class AbstractReader implements ReaderInterface */ public function setShouldFormatDates($shouldFormatDates) { - $this->shouldFormatDates = $shouldFormatDates; + $this->readerOptions->setShouldFormatDates($shouldFormatDates); + return $this; + } + + /** + * Sets whether to skip or return "empty" rows. + * + * @param bool $shouldPreserveEmptyRows + * @return AbstractReader + */ + public function setShouldPreserveEmptyRows($shouldPreserveEmptyRows) + { + $this->readerOptions->setShouldPreserveEmptyRows($shouldPreserveEmptyRows); return $this; } diff --git a/src/Spout/Reader/ODS/Reader.php b/src/Spout/Reader/ODS/Reader.php index a52bafa..a713729 100644 --- a/src/Spout/Reader/ODS/Reader.php +++ b/src/Spout/Reader/ODS/Reader.php @@ -42,7 +42,7 @@ class Reader extends AbstractReader $this->zip = new \ZipArchive(); if ($this->zip->open($filePath) === true) { - $this->sheetIterator = new SheetIterator($filePath, $this->shouldFormatDates); + $this->sheetIterator = new SheetIterator($filePath, $this->readerOptions); } else { throw new IOException("Could not open $filePath for reading."); } diff --git a/src/Spout/Reader/ODS/RowIterator.php b/src/Spout/Reader/ODS/RowIterator.php index e91ad90..08a400b 100644 --- a/src/Spout/Reader/ODS/RowIterator.php +++ b/src/Spout/Reader/ODS/RowIterator.php @@ -8,6 +8,7 @@ use Box\Spout\Reader\Exception\XMLProcessingException; use Box\Spout\Reader\IteratorInterface; use Box\Spout\Reader\ODS\Helper\CellValueFormatter; use Box\Spout\Reader\Wrapper\XMLReader; +use Box\Spout\Reader\ReaderOptions; /** * Class RowIterator @@ -21,10 +22,14 @@ class RowIterator implements IteratorInterface const XML_NODE_ROW = 'table:table-row'; const XML_NODE_CELL = 'table:table-cell'; const MAX_COLUMNS_EXCEL = 16384; + const MAX_ROWS_EXCEL = 1048576; /** Definition of XML attribute used to parse data */ const XML_ATTRIBUTE_NUM_COLUMNS_REPEATED = 'table:number-columns-repeated'; + /** Definition of XML attribute used to parse data */ + const XML_ATTRIBUTE_NUM_ROWS_REPEATED = 'table:number-rows-repeated'; + /** @var \Box\Spout\Reader\Wrapper\XMLReader The XMLReader object that will help read sheet's XML data */ protected $xmlReader; @@ -34,23 +39,27 @@ class RowIterator implements IteratorInterface /** @var bool Whether the iterator has already been rewound once */ protected $hasAlreadyBeenRewound = false; - /** @var int Number of read rows */ - protected $numReadRows = 0; + /** @var int Key for iterator */ + protected $rowIndex = 0; - /** @var array|null Buffer used to store the row data, while checking if there are more rows to read */ - protected $rowDataBuffer = null; + /** @var array Buffer used to store the row data, while checking if there are more rows to read */ + protected $rowDataBuffer = []; /** @var bool Indicates whether all rows have been read */ protected $hasReachedEndOfFile = false; + /** @var \Box\Spout\Reader\ReaderOptions */ + protected $readerOptions; + /** * @param XMLReader $xmlReader XML Reader, positioned on the "" element - * @param bool $shouldFormatDates Whether date/time values should be returned as PHP objects or be formatted as strings + * @param \Box\Spout\Reader\ReaderOptions $readerOptions */ - public function __construct($xmlReader, $shouldFormatDates) + public function __construct($xmlReader, ReaderOptions $readerOptions) { $this->xmlReader = $xmlReader; - $this->cellValueFormatter = new CellValueFormatter($shouldFormatDates); + $this->readerOptions = $readerOptions; + $this->cellValueFormatter = new CellValueFormatter($readerOptions->shouldFormatDates()); } /** @@ -71,8 +80,8 @@ class RowIterator implements IteratorInterface } $this->hasAlreadyBeenRewound = true; - $this->numReadRows = 0; - $this->rowDataBuffer = null; + $this->rowIndex = 0; + $this->rowDataBuffer = []; $this->hasReachedEndOfFile = false; $this->next(); @@ -90,7 +99,7 @@ class RowIterator implements IteratorInterface } /** - * Move forward to next element. Empty rows will be skipped. + * Move forward to next element. Empty rows can be skipped. * @link http://php.net/manual/en/iterator.next.php * * @return void @@ -99,15 +108,34 @@ class RowIterator implements IteratorInterface */ public function next() { + $prevRow = null; + + if (count($this->rowDataBuffer) > 1) { + array_shift($this->rowDataBuffer); + $this->rowIndex++; + + return; + } else { + $prevRow = $this->current(); + $this->rowDataBuffer = []; + } + $rowData = []; $cellValue = null; + $numRowsRepeated = 0; $numColumnsRepeated = 1; $numCellsRead = 0; $hasAlreadyReadOneCell = false; try { while ($this->xmlReader->read()) { - if ($this->xmlReader->isPositionedOnStartingNode(self::XML_NODE_CELL)) { + if ($this->xmlReader->isPositionedOnStartingNode(self::XML_NODE_ROW)) { + // Start of a row description + $this->rowIndex++; + + $numRowsRepeated = $this->getNumRowsRepeatedForCurrentNode(); + + } elseif ($this->xmlReader->isPositionedOnStartingNode(self::XML_NODE_CELL)) { // Start of a cell description $currentNumColumnsRepeated = $this->getNumColumnsRepeatedForCurrentNode(); @@ -127,30 +155,37 @@ class RowIterator implements IteratorInterface $numCellsRead++; $hasAlreadyReadOneCell = true; - } else if ($this->xmlReader->isPositionedOnEndingNode(self::XML_NODE_ROW)) { + } elseif ($this->xmlReader->isPositionedOnEndingNode(self::XML_NODE_ROW)) { // End of the row description $isEmptyRow = ($numCellsRead <= 1 && $this->isEmptyCellValue($cellValue)); - if ($isEmptyRow) { - // skip empty rows - $this->next(); - return; + + if (!$isEmptyRow) { + // Only add the value if the last read cell is not a trailing empty cell repeater in Excel. + // The current count of read columns is determined by counting the values in $rowData. + // This is to avoid creating a lot of empty cells, as Excel adds a last empty "" + // with a number-columns-repeated value equals to the number of (supported columns - used columns). + // In Excel, the number of supported columns is 16384, but we don't want to returns rows with + // always 16384 cells. + if ((count($rowData) + $numColumnsRepeated) !== self::MAX_COLUMNS_EXCEL) { + for ($i = 0; $i < $numColumnsRepeated; $i++) { + $rowData[] = $cellValue; + } + } + } elseif ($this->readerOptions->shouldPreserveEmptyRows()) { + // Take number of cells from the previously read line. + $rowData = empty($prevRow) ? [] : array_fill(0, count($prevRow), ''); + } else { + return $this->next(); } - // Only add the value if the last read cell is not a trailing empty cell repeater in Excel. - // The current count of read columns is determined by counting the values in $rowData. - // This is to avoid creating a lot of empty cells, as Excel adds a last empty "" - // with a number-columns-repeated value equals to the number of (supported columns - used columns). - // In Excel, the number of supported columns is 16384, but we don't want to returns rows with - // always 16384 cells. - if ((count($rowData) + $numColumnsRepeated) !== self::MAX_COLUMNS_EXCEL) { - for ($i = 0; $i < $numColumnsRepeated; $i++) { - $rowData[] = $cellValue; - } - $this->numReadRows++; + // see above, now check number of rows... + if ($this->rowIndex - 1 + $numRowsRepeated >= self::MAX_ROWS_EXCEL) { + $numRowsRepeated = 0; + $this->hasReachedEndOfFile = true; } break; - } else if ($this->xmlReader->isPositionedOnEndingNode(self::XML_NODE_TABLE)) { + } elseif ($this->xmlReader->isPositionedOnEndingNode(self::XML_NODE_TABLE)) { // The closing "" marks the end of the file $this->hasReachedEndOfFile = true; break; @@ -161,7 +196,9 @@ class RowIterator implements IteratorInterface throw new IOException("The sheet's data cannot be read. [{$exception->getMessage()}]"); } - $this->rowDataBuffer = $rowData; + for ($i = 0; $i < $numRowsRepeated; ++$i) { + $this->rowDataBuffer[] = $rowData; + } } /** @@ -173,6 +210,15 @@ class RowIterator implements IteratorInterface return ($numColumnsRepeated !== null) ? intval($numColumnsRepeated) : 1; } + /** + * @return int The value of "table:number-rows-repeated" attribute of the current node, or 1 if attribute missing + */ + protected function getNumRowsRepeatedForCurrentNode() + { + $numRowsRepeated = $this->xmlReader->getAttribute(self::XML_ATTRIBUTE_NUM_ROWS_REPEATED); + return ($numRowsRepeated !== null) ? intval($numRowsRepeated) : 1; + } + /** * Returns the (unescaped) correctly marshalled, cell value associated to the given XML node. * @@ -203,7 +249,7 @@ class RowIterator implements IteratorInterface */ public function current() { - return $this->rowDataBuffer; + return isset($this->rowDataBuffer[0]) ? $this->rowDataBuffer[0] : null; } /** @@ -214,10 +260,9 @@ class RowIterator implements IteratorInterface */ public function key() { - return $this->numReadRows; + return $this->rowIndex; } - /** * Cleans up what was created to iterate over the object. * diff --git a/src/Spout/Reader/ODS/Sheet.php b/src/Spout/Reader/ODS/Sheet.php index 98d00b1..f2869a4 100644 --- a/src/Spout/Reader/ODS/Sheet.php +++ b/src/Spout/Reader/ODS/Sheet.php @@ -4,6 +4,7 @@ namespace Box\Spout\Reader\ODS; use Box\Spout\Reader\SheetInterface; use Box\Spout\Reader\Wrapper\XMLReader; +use Box\Spout\Reader\ReaderOptions; /** * Class Sheet @@ -27,13 +28,13 @@ class Sheet implements SheetInterface /** * @param XMLReader $xmlReader XML Reader, positioned on the "" element - * @param bool $shouldFormatDates Whether date/time values should be returned as PHP objects or be formatted as strings + * @param \Box\Spout\Reader\ReaderOptions $readerOptions * @param int $sheetIndex Index of the sheet, based on order in the workbook (zero-based) * @param string $sheetName Name of the sheet */ - public function __construct($xmlReader, $shouldFormatDates, $sheetIndex, $sheetName) + public function __construct($xmlReader, ReaderOptions $readerOptions, $sheetIndex, $sheetName) { - $this->rowIterator = new RowIterator($xmlReader, $shouldFormatDates); + $this->rowIterator = new RowIterator($xmlReader, $readerOptions); $this->index = $sheetIndex; $this->name = $sheetName; } diff --git a/src/Spout/Reader/ODS/SheetIterator.php b/src/Spout/Reader/ODS/SheetIterator.php index f6cfdbe..dc41e7a 100644 --- a/src/Spout/Reader/ODS/SheetIterator.php +++ b/src/Spout/Reader/ODS/SheetIterator.php @@ -6,6 +6,7 @@ use Box\Spout\Common\Exception\IOException; use Box\Spout\Reader\Exception\XMLProcessingException; use Box\Spout\Reader\IteratorInterface; use Box\Spout\Reader\Wrapper\XMLReader; +use Box\Spout\Reader\ReaderOptions; /** * Class SheetIterator @@ -24,8 +25,8 @@ class SheetIterator implements IteratorInterface /** @var string $filePath Path of the file to be read */ protected $filePath; - /** @var bool Whether date/time values should be returned as PHP objects or be formatted as strings */ - protected $shouldFormatDates; + /** @var \Box\Spout\Reader\ReaderOptions */ + protected $readerOptions; /** @var XMLReader The XMLReader object that will help read sheet's XML data */ protected $xmlReader; @@ -41,13 +42,13 @@ class SheetIterator implements IteratorInterface /** * @param string $filePath Path of the file to be read - * @param bool $shouldFormatDates Whether date/time values should be returned as PHP objects or be formatted as strings + * @param \Box\Spout\Reader\ReaderOptions $readerOptions * @throws \Box\Spout\Reader\Exception\NoSheetsFoundException If there are no sheets in the file */ - public function __construct($filePath, $shouldFormatDates) + public function __construct($filePath, ReaderOptions $readerOptions) { $this->filePath = $filePath; - $this->shouldFormatDates = $shouldFormatDates; + $this->readerOptions = $readerOptions; $this->xmlReader = new XMLReader(); /** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */ @@ -116,7 +117,7 @@ class SheetIterator implements IteratorInterface $escapedSheetName = $this->xmlReader->getAttribute(self::XML_ATTRIBUTE_TABLE_NAME); $sheetName = $this->escaper->unescape($escapedSheetName); - return new Sheet($this->xmlReader, $this->shouldFormatDates, $sheetName, $this->currentSheetIndex); + return new Sheet($this->xmlReader, $this->readerOptions, $sheetName, $this->currentSheetIndex); } /** diff --git a/src/Spout/Reader/ReaderOptions.php b/src/Spout/Reader/ReaderOptions.php new file mode 100644 index 0000000..782947d --- /dev/null +++ b/src/Spout/Reader/ReaderOptions.php @@ -0,0 +1,62 @@ +shouldFormatDates = (bool)$shouldFormatDates; + return $this; + } + + /** + * Sets whether to skip or return "empty" rows. + * + * @param bool $shouldPreserveEmptyRows + * @return ReaderOptions + */ + public function setShouldPreserveEmptyRows($shouldPreserveEmptyRows) + { + $this->shouldPreserveEmptyRows = (bool)$shouldPreserveEmptyRows; + return $this; + } + + /** + * @see setShouldFormatDates + * @return bool + */ + public function shouldFormatDates() + { + return $this->shouldFormatDates; + } + + /** + * @see setShouldPreserveEmptyRows + * @return bool + */ + public function shouldPreserveEmptyRows() + { + return $this->shouldPreserveEmptyRows; + } + +} diff --git a/src/Spout/Reader/XLSX/Helper/SheetHelper.php b/src/Spout/Reader/XLSX/Helper/SheetHelper.php index a6ff909..3886e4d 100644 --- a/src/Spout/Reader/XLSX/Helper/SheetHelper.php +++ b/src/Spout/Reader/XLSX/Helper/SheetHelper.php @@ -4,6 +4,7 @@ namespace Box\Spout\Reader\XLSX\Helper; use Box\Spout\Reader\Wrapper\XMLReader; use Box\Spout\Reader\XLSX\Sheet; +use Box\Spout\Reader\ReaderOptions; /** * Class SheetHelper @@ -26,21 +27,21 @@ class SheetHelper /** @var \Box\Spout\Common\Helper\GlobalFunctionsHelper Helper to work with global functions */ protected $globalFunctionsHelper; - /** @var bool Whether date/time values should be returned as PHP objects or be formatted as strings */ - protected $shouldFormatDates; + /** @var \Box\Spout\Reader\ReaderOptions */ + protected $readerOptions; /** * @param string $filePath Path of the XLSX file being read * @param \Box\Spout\Reader\XLSX\Helper\SharedStringsHelper Helper to work with shared strings * @param \Box\Spout\Common\Helper\GlobalFunctionsHelper $globalFunctionsHelper - * @param bool $shouldFormatDates Whether date/time values should be returned as PHP objects or be formatted as strings + * @param \Box\Spout\Reader\ReaderOptions $readerOptions */ - public function __construct($filePath, $sharedStringsHelper, $globalFunctionsHelper, $shouldFormatDates) + public function __construct($filePath, $sharedStringsHelper, $globalFunctionsHelper, ReaderOptions $readerOptions) { $this->filePath = $filePath; $this->sharedStringsHelper = $sharedStringsHelper; $this->globalFunctionsHelper = $globalFunctionsHelper; - $this->shouldFormatDates = $shouldFormatDates; + $this->readerOptions = $readerOptions; } /** @@ -92,7 +93,7 @@ class SheetHelper $sheetDataXMLFilePath = $this->getSheetDataXMLFilePathForSheetId($sheetId); - return new Sheet($this->filePath, $sheetDataXMLFilePath, $this->sharedStringsHelper, $this->shouldFormatDates, $sheetIndexZeroBased, $sheetName); + return new Sheet($this->filePath, $sheetDataXMLFilePath, $this->sharedStringsHelper, $this->readerOptions, $sheetIndexZeroBased, $sheetName); } /** diff --git a/src/Spout/Reader/XLSX/Reader.php b/src/Spout/Reader/XLSX/Reader.php index bcf02cc..1d6f7e8 100644 --- a/src/Spout/Reader/XLSX/Reader.php +++ b/src/Spout/Reader/XLSX/Reader.php @@ -69,7 +69,7 @@ class Reader extends AbstractReader $this->sharedStringsHelper->extractSharedStrings(); } - $this->sheetIterator = new SheetIterator($filePath, $this->sharedStringsHelper, $this->globalFunctionsHelper, $this->shouldFormatDates); + $this->sheetIterator = new SheetIterator($filePath, $this->sharedStringsHelper, $this->globalFunctionsHelper, $this->readerOptions); } else { throw new IOException("Could not open $filePath for reading."); } diff --git a/src/Spout/Reader/XLSX/RowIterator.php b/src/Spout/Reader/XLSX/RowIterator.php index c7491ac..666178c 100644 --- a/src/Spout/Reader/XLSX/RowIterator.php +++ b/src/Spout/Reader/XLSX/RowIterator.php @@ -9,6 +9,7 @@ use Box\Spout\Reader\Wrapper\XMLReader; use Box\Spout\Reader\XLSX\Helper\CellHelper; use Box\Spout\Reader\XLSX\Helper\CellValueFormatter; use Box\Spout\Reader\XLSX\Helper\StyleHelper; +use Box\Spout\Reader\ReaderOptions; /** * Class RowIterator @@ -43,11 +44,11 @@ class RowIterator implements IteratorInterface /** @var Helper\StyleHelper $styleHelper Helper to work with styles */ protected $styleHelper; - /** @var int Number of read rows */ - protected $numReadRows = 0; + /** @var int Key for iterator */ + protected $rowIndex = 0; - /** @var array|null Buffer used to store the row data, while checking if there are more rows to read */ - protected $rowDataBuffer = null; + /** @var array Buffer used to store the row data, while checking if there are more rows to read */ + protected $rowDataBuffer = []; /** @var bool Indicates whether all rows have been read */ protected $hasReachedEndOfFile = false; @@ -55,13 +56,16 @@ class RowIterator implements IteratorInterface /** @var int The number of columns the sheet has (0 meaning undefined) */ protected $numColumns = 0; + /** @var \Box\Spout\Reader\ReaderOptions */ + protected $readerOptions; + /** * @param string $filePath Path of the XLSX file being read * @param string $sheetDataXMLFilePath Path of the sheet data XML file as in [Content_Types].xml * @param Helper\SharedStringsHelper $sharedStringsHelper Helper to work with shared strings - * @param bool $shouldFormatDates Whether date/time values should be returned as PHP objects or be formatted as strings + * @param \Box\Spout\Reader\ReaderOptions $readerOptions */ - public function __construct($filePath, $sheetDataXMLFilePath, $sharedStringsHelper, $shouldFormatDates) + public function __construct($filePath, $sheetDataXMLFilePath, $sharedStringsHelper, ReaderOptions $readerOptions) { $this->filePath = $filePath; $this->sheetDataXMLFilePath = $this->normalizeSheetDataXMLFilePath($sheetDataXMLFilePath); @@ -69,7 +73,8 @@ class RowIterator implements IteratorInterface $this->xmlReader = new XMLReader(); $this->styleHelper = new StyleHelper($filePath); - $this->cellValueFormatter = new CellValueFormatter($sharedStringsHelper, $this->styleHelper, $shouldFormatDates); + $this->readerOptions = $readerOptions; + $this->cellValueFormatter = new CellValueFormatter($sharedStringsHelper, $this->styleHelper, $readerOptions->shouldFormatDates()); } /** @@ -101,7 +106,7 @@ class RowIterator implements IteratorInterface } $this->numReadRows = 0; - $this->rowDataBuffer = null; + $this->rowDataBuffer = []; $this->hasReachedEndOfFile = false; $this->numColumns = 0; @@ -131,6 +136,15 @@ class RowIterator implements IteratorInterface { $rowData = []; + if (count($this->rowDataBuffer) > 1) { + array_shift($this->rowDataBuffer); + $this->rowIndex++; + + return; + } else { + $this->rowDataBuffer = []; + } + try { while ($this->xmlReader->read()) { if ($this->xmlReader->isPositionedOnStartingNode(self::XML_NODE_DIMENSION)) { @@ -141,8 +155,10 @@ class RowIterator implements IteratorInterface $this->numColumns = CellHelper::getColumnIndexFromCellIndex($lastCellIndex) + 1; } - } else if ($this->xmlReader->isPositionedOnStartingNode(self::XML_NODE_ROW)) { + } elseif ($this->xmlReader->isPositionedOnStartingNode(self::XML_NODE_ROW)) { // Start of the row description + $prevRowIndex = $this->rowIndex; + $newRowIndex = $this->xmlReader->getAttribute(self::XML_ATTRIBUTE_CELL_INDEX); // Read spans info if present $numberOfColumnsForRow = $this->numColumns; @@ -153,7 +169,15 @@ class RowIterator implements IteratorInterface } $rowData = ($numberOfColumnsForRow !== 0) ? array_fill(0, $numberOfColumnsForRow, '') : []; - } else if ($this->xmlReader->isPositionedOnStartingNode(self::XML_NODE_CELL)) { + if ($this->readerOptions->shouldPreserveEmptyRows()) { + for ($i = $prevRowIndex + 1; $i < $newRowIndex; ++$i) { + $this->rowDataBuffer[] = $rowData; // fake empty rows + } + } + + $this->rowIndex = $newRowIndex - count($this->rowDataBuffer); + + } elseif ($this->xmlReader->isPositionedOnStartingNode(self::XML_NODE_CELL)) { // Start of a cell description $currentCellIndex = $this->xmlReader->getAttribute(self::XML_ATTRIBUTE_CELL_INDEX); $currentColumnIndex = CellHelper::getColumnIndexFromCellIndex($currentCellIndex); @@ -161,14 +185,14 @@ class RowIterator implements IteratorInterface $node = $this->xmlReader->expand(); $rowData[$currentColumnIndex] = $this->getCellValue($node); - } else if ($this->xmlReader->isPositionedOnEndingNode(self::XML_NODE_ROW)) { + } elseif ($this->xmlReader->isPositionedOnEndingNode(self::XML_NODE_ROW)) { // End of the row description // If needed, we fill the empty cells $rowData = ($this->numColumns !== 0) ? $rowData : CellHelper::fillMissingArrayIndexes($rowData); - $this->numReadRows++; + break; - } else if ($this->xmlReader->isPositionedOnEndingNode(self::XML_NODE_WORKSHEET)) { + } elseif ($this->xmlReader->isPositionedOnEndingNode(self::XML_NODE_WORKSHEET)) { // The closing "" marks the end of the file $this->hasReachedEndOfFile = true; break; @@ -179,7 +203,7 @@ class RowIterator implements IteratorInterface throw new IOException("The {$this->sheetDataXMLFilePath} file cannot be read. [{$exception->getMessage()}]"); } - $this->rowDataBuffer = $rowData; + $this->rowDataBuffer[] = $rowData; } /** @@ -201,7 +225,7 @@ class RowIterator implements IteratorInterface */ public function current() { - return $this->rowDataBuffer; + return isset($this->rowDataBuffer[0]) ? $this->rowDataBuffer[0] : null; } /** @@ -212,7 +236,7 @@ class RowIterator implements IteratorInterface */ public function key() { - return $this->numReadRows; + return $this->rowIndex; } diff --git a/src/Spout/Reader/XLSX/Sheet.php b/src/Spout/Reader/XLSX/Sheet.php index a1c7d95..e60c5bc 100644 --- a/src/Spout/Reader/XLSX/Sheet.php +++ b/src/Spout/Reader/XLSX/Sheet.php @@ -3,6 +3,7 @@ namespace Box\Spout\Reader\XLSX; use Box\Spout\Reader\SheetInterface; +use Box\Spout\Reader\ReaderOptions; /** * Class Sheet @@ -25,13 +26,13 @@ class Sheet implements SheetInterface * @param string $filePath Path of the XLSX file being read * @param string $sheetDataXMLFilePath Path of the sheet data XML file as in [Content_Types].xml * @param Helper\SharedStringsHelper Helper to work with shared strings - * @param bool $shouldFormatDates Whether date/time values should be returned as PHP objects or be formatted as strings + * @param \Box\Spout\Reader\ReaderOptions $readerOptions * @param int $sheetIndex Index of the sheet, based on order in the workbook (zero-based) * @param string $sheetName Name of the sheet */ - public function __construct($filePath, $sheetDataXMLFilePath, $sharedStringsHelper, $shouldFormatDates, $sheetIndex, $sheetName) + public function __construct($filePath, $sheetDataXMLFilePath, $sharedStringsHelper, ReaderOptions $readerOptions, $sheetIndex, $sheetName) { - $this->rowIterator = new RowIterator($filePath, $sheetDataXMLFilePath, $sharedStringsHelper, $shouldFormatDates); + $this->rowIterator = new RowIterator($filePath, $sheetDataXMLFilePath, $sharedStringsHelper, $readerOptions); $this->index = $sheetIndex; $this->name = $sheetName; } diff --git a/src/Spout/Reader/XLSX/SheetIterator.php b/src/Spout/Reader/XLSX/SheetIterator.php index f7a3f59..8685f2d 100644 --- a/src/Spout/Reader/XLSX/SheetIterator.php +++ b/src/Spout/Reader/XLSX/SheetIterator.php @@ -5,6 +5,7 @@ namespace Box\Spout\Reader\XLSX; use Box\Spout\Reader\IteratorInterface; use Box\Spout\Reader\XLSX\Helper\SheetHelper; use Box\Spout\Reader\Exception\NoSheetsFoundException; +use Box\Spout\Reader\ReaderOptions; /** * Class SheetIterator @@ -24,13 +25,13 @@ class SheetIterator implements IteratorInterface * @param string $filePath Path of the file to be read * @param \Box\Spout\Reader\XLSX\Helper\SharedStringsHelper $sharedStringsHelper * @param \Box\Spout\Common\Helper\GlobalFunctionsHelper $globalFunctionsHelper - * @param bool $shouldFormatDates Whether date/time values should be returned as PHP objects or be formatted as strings + * @param \Box\Spout\Reader\ReaderOptions $readerOptions * @throws \Box\Spout\Reader\Exception\NoSheetsFoundException If there are no sheets in the file */ - public function __construct($filePath, $sharedStringsHelper, $globalFunctionsHelper, $shouldFormatDates) + public function __construct($filePath, $sharedStringsHelper, $globalFunctionsHelper, ReaderOptions $readerOptions) { // Fetch all available sheets - $sheetHelper = new SheetHelper($filePath, $sharedStringsHelper, $globalFunctionsHelper, $shouldFormatDates); + $sheetHelper = new SheetHelper($filePath, $sharedStringsHelper, $globalFunctionsHelper, $readerOptions); $this->sheets = $sheetHelper->getSheets(); if (count($this->sheets) === 0) { diff --git a/tests/Spout/Reader/ODS/ReaderTest.php b/tests/Spout/Reader/ODS/ReaderTest.php index dee4164..5b5f709 100644 --- a/tests/Spout/Reader/ODS/ReaderTest.php +++ b/tests/Spout/Reader/ODS/ReaderTest.php @@ -137,6 +137,24 @@ class ReaderTest extends \PHPUnit_Framework_TestCase $this->assertEquals($expectedRows, $allRows); } + /** + * @dataProvider dataProviderForTestReadWithFilesGeneratedByExternalSoftwares + * + * @param bool $skipLastEmptyValues + * @param string $fileName + * @return void + */ + public function testReadWithFilesGeneratedByExternalSoftwareAndEmptyRowsPreserved($fileName, $skipLastEmptyValues) + { + $reader = ReaderFactory::create(Type::ODS); + $reader->setShouldPreserveEmptyRows(true); + $allRows = $this->getAllRowsForFile($fileName, $reader); + + foreach ($allRows as $index => $row) { +// :TODO: write useful test +// $this->assertCount($expectedColumns, $row); + } + } /** * @return void @@ -169,8 +187,10 @@ class ReaderTest extends \PHPUnit_Framework_TestCase */ public function testReadShouldSupportFormatDatesAndTimesIfSpecified() { - $shouldFormatDates = true; - $allRows = $this->getAllRowsForFile('sheet_with_dates_and_times.ods', $shouldFormatDates); + $reader = ReaderFactory::create(Type::ODS); + $reader->setShouldFormatDates(true); + + $allRows = $this->getAllRowsForFile('sheet_with_dates_and_times.ods', $reader); $expectedRows = [ ['05/19/2016', '5/19/16', '05/19/2016 16:39:00', '05/19/16 04:39 PM', '5/19/2016'], @@ -213,13 +233,48 @@ class ReaderTest extends \PHPUnit_Framework_TestCase */ public function testReadShouldSkipEmptyRow() { - $allRows = $this->getAllRowsForFile('sheet_with_empty_row.ods'); + $allRows = $this->getAllRowsForFirstSheet('sheet_with_empty_row.ods'); $this->assertEquals(2, count($allRows), 'There should be only 2 rows, because the empty row is skipped'); $expectedRows = [ - ['ods--11', 'ods--12', 'ods--13'], + 1 => ['ods--11', 'ods--12', 'ods--13'], // row skipped here - ['ods--21', 'ods--22', 'ods--23'], + 3 => ['ods--21', 'ods--22', 'ods--23'], + ]; + $this->assertEquals($expectedRows, $allRows); + } + + /** + * @return void + */ + public function testReadShouldPreserveEmptyRow() + { + $reader = ReaderFactory::create(Type::ODS); + $reader->setShouldPreserveEmptyRows(true); + $allRows = $this->getAllRowsForFirstSheet('sheet_with_empty_row.ods', $reader); + + $expectedRows = [ + 1 => ['ods--11', 'ods--12', 'ods--13'], + 2 => ['', '', ''], + 3 => ['ods--21', 'ods--22', 'ods--23'], + ]; + $this->assertEquals($expectedRows, $allRows); + } + + /** + * @return void + */ + public function testReadShouldPreserveConsecutiveEmptyRows() + { + $reader = ReaderFactory::create(Type::ODS); + $reader->setShouldPreserveEmptyRows(true); + $allRows = $this->getAllRowsForFirstSheet('sheet_with_consecutive_empty_rows.ods', $reader); + + $expectedRows = [ + 1 => ['First'], + 2 => [''], + 3 => [''], + 4 => ['Second'], ]; $this->assertEquals($expectedRows, $allRows); } @@ -241,6 +296,29 @@ class ReaderTest extends \PHPUnit_Framework_TestCase $this->assertEquals([$expectedRow], $allRows); } + /** + * @return void + */ + public function testReadShouldHandleRepeatedRows() + { + $expectedRows = [ + 1 => ['First'], + 2 => ['First'], + 3 => ['First'], + 4 => ['Second'], + 5 => ['Third'], + 6 => ['Third'], + ]; + + $reader = ReaderFactory::create(Type::ODS); + $reader->setShouldPreserveEmptyRows(false); + $allRows = $this->getAllRowsForFirstSheet('sheet_with_repeated_rows.ods', $reader); + $this->assertEquals($expectedRows, $allRows); + + $reader->setShouldPreserveEmptyRows(true); + $allRows = $this->getAllRowsForFirstSheet('sheet_with_repeated_rows.ods', $reader); + $this->assertEquals($expectedRows, $allRows); + } /** * @NOTE: The LIBXML_NOENT is used to ACTUALLY substitute entities (and should therefore not be used) @@ -484,22 +562,49 @@ class ReaderTest extends \PHPUnit_Framework_TestCase /** * @param string $fileName - * @param bool|void $shouldFormatDates - * @return array All the read rows the given file + * @param Reader $reader + * @return array */ - private function getAllRowsForFile($fileName, $shouldFormatDates = false) + private function getAllRowsForFile($fileName, Reader $reader = null) { $allRows = []; $resourcePath = $this->getResourcePath($fileName); - $reader = ReaderFactory::create(Type::ODS); - $reader->setShouldFormatDates($shouldFormatDates); + if (!$reader) { + $reader = ReaderFactory::create(Type::ODS); + } + $reader->open($resourcePath); foreach ($reader->getSheetIterator() as $sheetIndex => $sheet) { - foreach ($sheet->getRowIterator() as $rowIndex => $row) { - $allRows[] = $row; - } + $allRows = array_merge($allRows, iterator_to_array($sheet->getRowIterator(), false)); + } + + $reader->close(); + + return $allRows; + } + + + /** + * @param string $fileName + * @param Reader $reader + * @return array + */ + private function getAllRowsForFirstSheet($fileName, Reader $reader = null) + { + $allRows = []; + $resourcePath = $this->getResourcePath($fileName); + + if (!$reader) { + $reader = ReaderFactory::create(Type::ODS); + } + + $reader->open($resourcePath); + + foreach ($reader->getSheetIterator() as $sheet) { + $allRows = iterator_to_array($sheet->getRowIterator(), true); + break; } $reader->close(); diff --git a/tests/Spout/Reader/XLSX/ReaderTest.php b/tests/Spout/Reader/XLSX/ReaderTest.php index c705f2f..21e619d 100644 --- a/tests/Spout/Reader/XLSX/ReaderTest.php +++ b/tests/Spout/Reader/XLSX/ReaderTest.php @@ -239,8 +239,10 @@ class ReaderTest extends \PHPUnit_Framework_TestCase */ public function testReadShouldSupportFormatDatesAndTimesIfSpecified() { - $shouldFormatDates = true; - $allRows = $this->getAllRowsForFile('sheet_with_dates_and_times.xlsx', $shouldFormatDates); + $reader = ReaderFactory::create(Type::XLSX); + $reader->setShouldFormatDates(true); + + $allRows = $this->getAllRowsForFile('sheet_with_dates_and_times.xlsx', $reader); $expectedRows = [ ['1/13/2016', '01/13/2016', '13-Jan-16', 'Wednesday January 13, 16', 'Today is 1/13/2016'], @@ -307,16 +309,53 @@ class ReaderTest extends \PHPUnit_Framework_TestCase /** * @return void */ - public function testReadShouldSkipEmptyRows() + public function testReadShouldSkipEmptyRow() { - $allRows = $this->getAllRowsForFile('sheet_with_empty_row.xlsx'); + $allRows = $this->getAllRowsForFirstSheet('sheet_with_empty_row.xlsx'); $this->assertEquals(2, count($allRows), 'There should be only 2 rows, because the empty row is skipped'); $expectedRows = [ - ['s1--A1', 's1--B1', 's1--C1', 's1--D1', 's1--E1'], + 1 => ['s1--A1', 's1--B1', 's1--C1', 's1--D1', 's1--E1'], // skipped row here - ['s1--A3', 's1--B3', 's1--C3', 's1--D3', 's1--E3'], + 3 => ['s1--A3', 's1--B3', 's1--C3', 's1--D3', 's1--E3'], + ]; + $this->assertEquals($expectedRows, $allRows); + } + + /** + * @return void + */ + public function testReadShouldPreserveEmptyRow() + { + $reader = ReaderFactory::create(Type::XLSX); + $reader->setShouldPreserveEmptyRows(true); + $allRows = $this->getAllRowsForFirstSheet('sheet_with_empty_row.xlsx', $reader); + + $expectedRows = [ + 1 => ['s1--A1', 's1--B1', 's1--C1', 's1--D1', 's1--E1'], + 2 => ['', '', '', '', ''], + 3 => ['s1--A3', 's1--B3', 's1--C3', 's1--D3', 's1--E3'], + ]; + $this->assertEquals($expectedRows, $allRows); + } + + /** + * @return void + */ + public function testReadShouldPreserveConsecutiveEmptyRows() + { + $reader = ReaderFactory::create(Type::XLSX); + $reader->setShouldPreserveEmptyRows(true); + $allRows = $this->getAllRowsForFirstSheet('sheet_with_consecutive_empty_rows.xlsx', $reader); + + $expectedRows = [ + 1 => ['First'], + 2 => [''], + 3 => [''], + 4 => [''], + 5 => ['Second'], + 6 => ['Third'], ]; $this->assertEquals($expectedRows, $allRows); } @@ -549,22 +588,49 @@ class ReaderTest extends \PHPUnit_Framework_TestCase /** * @param string $fileName - * @param bool|void $shouldFormatDates - * @return array All the read rows the given file + * @param Reader $reader + * @return array */ - private function getAllRowsForFile($fileName, $shouldFormatDates = false) + private function getAllRowsForFile($fileName, Reader $reader = null) { $allRows = []; $resourcePath = $this->getResourcePath($fileName); - $reader = ReaderFactory::create(Type::XLSX); - $reader->setShouldFormatDates($shouldFormatDates); + if (!$reader) { + $reader = ReaderFactory::create(Type::XLSX); + } + $reader->open($resourcePath); foreach ($reader->getSheetIterator() as $sheetIndex => $sheet) { - foreach ($sheet->getRowIterator() as $rowIndex => $row) { - $allRows[] = $row; - } + $allRows = array_merge($allRows, iterator_to_array($sheet->getRowIterator(), false)); + } + + $reader->close(); + + return $allRows; + } + + + /** + * @param string $fileName + * @param Reader $reader + * @return array + */ + private function getAllRowsForFirstSheet($fileName, Reader $reader = null) + { + $allRows = []; + $resourcePath = $this->getResourcePath($fileName); + + if (!$reader) { + $reader = ReaderFactory::create(Type::XLSX); + } + + $reader->open($resourcePath); + + foreach ($reader->getSheetIterator() as $sheet) { + $allRows = iterator_to_array($sheet->getRowIterator(), true); + break; } $reader->close(); diff --git a/tests/resources/ods/sheet_with_consecutive_empty_rows.ods b/tests/resources/ods/sheet_with_consecutive_empty_rows.ods new file mode 100644 index 0000000000000000000000000000000000000000..558e24a3f80c43679cd2b76464bee3a625ecec92 GIT binary patch literal 7989 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 b|4)+C0-+&x7XZLU{9Yhx_zXfC1pxjJzn4xp literal 0 HcmV?d00001