From c74c0d91273fd6f8af3cac87a1562113d32fb2bd Mon Sep 17 00:00:00 2001 From: Adrien Loison Date: Sat, 4 Nov 2017 12:46:03 +0100 Subject: [PATCH] Add support for 1904 dates This commit adds support for dates using the 1904 calendar (starting 1904-01-01 00:00:00). It also fixes some issues with the dates in 1900 calendar (which now correctly start at 1899-12-30 00:00:00). Finally, it is now possible to have negative timestamps, representing dates before the base date (and up to 0000-01-01 00:00:00), as per the SpreadsheetML specs. Note that some versions of Excel don't support negative dates... --- src/Spout/Reader/Common/Entity/Options.php | 1 + .../Reader/XLSX/Creator/EntityFactory.php | 9 +- .../Reader/XLSX/Creator/HelperFactory.php | 5 +- .../Reader/XLSX/Helper/CellValueFormatter.php | 95 +++++++----------- .../Reader/XLSX/Manager/OptionsManager.php | 2 + .../Reader/XLSX/Manager/SheetManager.php | 89 ++++++++++++---- .../XLSX/Helper/CellValueFormatterTest.php | 56 +++++++---- tests/Spout/Reader/XLSX/ReaderTest.php | 39 +++++-- ...ent_numeric_value_dates_1904_calendar.xlsx | Bin 0 -> 22508 bytes 9 files changed, 194 insertions(+), 102 deletions(-) create mode 100644 tests/resources/xlsx/sheet_with_different_numeric_value_dates_1904_calendar.xlsx diff --git a/src/Spout/Reader/Common/Entity/Options.php b/src/Spout/Reader/Common/Entity/Options.php index 615160d..293d4c0 100644 --- a/src/Spout/Reader/Common/Entity/Options.php +++ b/src/Spout/Reader/Common/Entity/Options.php @@ -19,4 +19,5 @@ abstract class Options // XLSX specific options const TEMP_FOLDER = 'tempFolder'; + const SHOULD_USE_1904_DATES = 'shouldUse1904Dates'; } diff --git a/src/Spout/Reader/XLSX/Creator/EntityFactory.php b/src/Spout/Reader/XLSX/Creator/EntityFactory.php index 18fe3ef..8699529 100644 --- a/src/Spout/Reader/XLSX/Creator/EntityFactory.php +++ b/src/Spout/Reader/XLSX/Creator/EntityFactory.php @@ -85,7 +85,14 @@ class EntityFactory implements EntityFactoryInterface $styleManager = $this->managerFactory->createStyleManager($filePath, $this); $shouldFormatDates = $optionsManager->getOption(Options::SHOULD_FORMAT_DATES); - $cellValueFormatter = $this->helperFactory->createCellValueFormatter($sharedStringsManager, $styleManager, $shouldFormatDates); + $shouldUse1904Dates = $optionsManager->getOption(Options::SHOULD_USE_1904_DATES); + + $cellValueFormatter = $this->helperFactory->createCellValueFormatter( + $sharedStringsManager, + $styleManager, + $shouldFormatDates, + $shouldUse1904Dates + ); $shouldPreserveEmptyRows = $optionsManager->getOption(Options::SHOULD_PRESERVE_EMPTY_ROWS); diff --git a/src/Spout/Reader/XLSX/Creator/HelperFactory.php b/src/Spout/Reader/XLSX/Creator/HelperFactory.php index 6546712..80ee692 100644 --- a/src/Spout/Reader/XLSX/Creator/HelperFactory.php +++ b/src/Spout/Reader/XLSX/Creator/HelperFactory.php @@ -17,13 +17,14 @@ class HelperFactory extends \Box\Spout\Common\Creator\HelperFactory * @param SharedStringsManager $sharedStringsManager Manages shared strings * @param StyleManager $styleManager Manages styles * @param bool $shouldFormatDates Whether date/time values should be returned as PHP objects or be formatted as strings + * @param bool $shouldUse1904Dates Whether date/time values should use a calendar starting in 1904 instead of 1900 * @return CellValueFormatter */ - public function createCellValueFormatter($sharedStringsManager, $styleManager, $shouldFormatDates) + public function createCellValueFormatter($sharedStringsManager, $styleManager, $shouldFormatDates, $shouldUse1904Dates) { $escaper = $this->createStringsEscaper(); - return new CellValueFormatter($sharedStringsManager, $styleManager, $shouldFormatDates, $escaper); + return new CellValueFormatter($sharedStringsManager, $styleManager, $shouldFormatDates, $shouldUse1904Dates, $escaper); } /** diff --git a/src/Spout/Reader/XLSX/Helper/CellValueFormatter.php b/src/Spout/Reader/XLSX/Helper/CellValueFormatter.php index 458765e..ccf7115 100644 --- a/src/Spout/Reader/XLSX/Helper/CellValueFormatter.php +++ b/src/Spout/Reader/XLSX/Helper/CellValueFormatter.php @@ -48,6 +48,9 @@ class CellValueFormatter /** @var bool Whether date/time values should be returned as PHP objects or be formatted as strings */ protected $shouldFormatDates; + /** @var bool Whether date/time values should use a calendar starting in 1904 instead of 1900 */ + protected $shouldUse1904Dates; + /** @var \Box\Spout\Common\Helper\Escaper\XLSX Used to unescape XML data */ protected $escaper; @@ -55,13 +58,15 @@ class CellValueFormatter * @param SharedStringsManager $sharedStringsManager Manages shared strings * @param StyleManager $styleManager Manages styles * @param bool $shouldFormatDates Whether date/time values should be returned as PHP objects or be formatted as strings + * @param bool $shouldUse1904Dates Whether date/time values should use a calendar starting in 1904 instead of 1900 * @param \Box\Spout\Common\Helper\Escaper\XLSX $escaper Used to unescape XML data */ - public function __construct($sharedStringsManager, $styleManager, $shouldFormatDates, $escaper) + public function __construct($sharedStringsManager, $styleManager, $shouldFormatDates, $shouldUse1904Dates, $escaper) { $this->sharedStringsManager = $sharedStringsManager; $this->styleManager = $styleManager; $this->shouldFormatDates = $shouldFormatDates; + $this->shouldUse1904Dates = $shouldUse1904Dates; $this->escaper = $escaper; } @@ -189,26 +194,20 @@ class CellValueFormatter /** * Returns a cell's PHP Date value, associated to the given timestamp. - * NOTE: The timestamp is a float representing the number of days since January 1st, 1900. + * NOTE: The timestamp is a float representing the number of days since the base Excel date: + * Dec 30th 1899, 1900 or Jan 1st, 1904, depending on the Workbook setting. * NOTE: The timestamp can also represent a time, if it is a value between 0 and 1. * + * @see ECMA-376 Part 1 - §18.17.4 + * * @param float $nodeValue * @param int $cellStyleId 0 being the default style * @return \DateTime|null The value associated with the cell or NULL if invalid date value */ protected function formatExcelTimestampValue($nodeValue, $cellStyleId) { - // Fix for the erroneous leap year in Excel - if (ceil($nodeValue) > self::ERRONEOUS_EXCEL_LEAP_YEAR_DAY) { - $nodeValue--; - } - - if ($nodeValue >= 1) { - // Values greater than 1 represent "dates". The value 1.0 representing the "base" date: 1900-01-01. - $cellValue = $this->formatExcelTimestampValueAsDateValue($nodeValue, $cellStyleId); - } elseif ($nodeValue >= 0) { - // Values between 0 and 1 represent "times". - $cellValue = $this->formatExcelTimestampValueAsTimeValue($nodeValue, $cellStyleId); + if ($this->isValidTimestampValue($nodeValue)) { + $cellValue = $this->formatExcelTimestampValueAsDateTimeValue($nodeValue, $cellStyleId); } else { // invalid date $cellValue = null; @@ -217,24 +216,42 @@ class CellValueFormatter return $cellValue; } + /** + * Returns whether the given timestamp is supported by SpreadsheetML + * @see ECMA-376 Part 1 - §18.17.4 - this specifies the timestamp boundaries. + * + * @param float $timestampValue + * @return bool + */ + protected function isValidTimestampValue($timestampValue) + { + // @NOTE: some versions of Excel don't support negative dates (e.g. Excel for Mac 2011) + return ( + $this->shouldUse1904Dates && $timestampValue >= -695055 && $timestampValue <= 2957003.9999884 || + !$this->shouldUse1904Dates && $timestampValue >= -693593 && $timestampValue <= 2958465.9999884 + ); + } + /** * Returns a cell's PHP DateTime value, associated to the given timestamp. - * Only the time value matters. The date part is set to Jan 1st, 1900 (base Excel date). + * Only the time value matters. The date part is set to the base Excel date: + * Dec 30th 1899, 1900 or Jan 1st, 1904, depending on the Workbook setting. * * @param float $nodeValue * @param int $cellStyleId 0 being the default style * @return \DateTime|string The value associated with the cell */ - protected function formatExcelTimestampValueAsTimeValue($nodeValue, $cellStyleId) + protected function formatExcelTimestampValueAsDateTimeValue($nodeValue, $cellStyleId) { - $time = round($nodeValue * self::NUM_SECONDS_IN_ONE_DAY); - $hours = floor($time / self::NUM_SECONDS_IN_ONE_HOUR); - $minutes = floor($time / self::NUM_SECONDS_IN_ONE_MINUTE) - ($hours * self::NUM_SECONDS_IN_ONE_MINUTE); - $seconds = $time - ($hours * self::NUM_SECONDS_IN_ONE_HOUR) - ($minutes * self::NUM_SECONDS_IN_ONE_MINUTE); + $baseDate = $this->shouldUse1904Dates ? '1904-01-01' : '1899-12-30'; - // using the base Excel date (Jan 1st, 1900) - not relevant here - $dateObj = new \DateTime('1900-01-01'); - $dateObj->setTime($hours, $minutes, $seconds); + $daysSinceBaseDate = (int) $nodeValue; + $timeRemainder = fmod($nodeValue, 1); + $secondsRemainder = round($timeRemainder * self::NUM_SECONDS_IN_ONE_DAY, 0); + + $dateObj = \DateTime::createFromFormat('|Y-m-d', $baseDate); + $dateObj->modify('+' . $daysSinceBaseDate . 'days'); + $dateObj->modify('+' . $secondsRemainder . 'seconds'); if ($this->shouldFormatDates) { $styleNumberFormatCode = $this->styleManager->getNumberFormatCode($cellStyleId); @@ -247,40 +264,6 @@ class CellValueFormatter return $cellValue; } - /** - * Returns a cell's PHP Date value, associated to the given timestamp. - * NOTE: The timestamp is a float representing the number of days since January 1st, 1900. - * - * @param float $nodeValue - * @param int $cellStyleId 0 being the default style - * @return \DateTime|string|null The value associated with the cell or NULL if invalid date value - */ - protected function formatExcelTimestampValueAsDateValue($nodeValue, $cellStyleId) - { - // Do not use any unix timestamps for calculation to prevent - // issues with numbers exceeding 2^31. - $secondsRemainder = fmod($nodeValue, 1) * self::NUM_SECONDS_IN_ONE_DAY; - $secondsRemainder = round($secondsRemainder, 0); - - try { - $dateObj = \DateTime::createFromFormat('|Y-m-d', '1899-12-31'); - $dateObj->modify('+' . (int) $nodeValue . 'days'); - $dateObj->modify('+' . $secondsRemainder . 'seconds'); - - if ($this->shouldFormatDates) { - $styleNumberFormatCode = $this->styleManager->getNumberFormatCode($cellStyleId); - $phpDateFormat = DateFormatHelper::toPHPDateFormat($styleNumberFormatCode); - $cellValue = $dateObj->format($phpDateFormat); - } else { - $cellValue = $dateObj; - } - } catch (\Exception $e) { - $cellValue = null; - } - - return $cellValue; - } - /** * Returns the cell Boolean value from a specific node's Value. * diff --git a/src/Spout/Reader/XLSX/Manager/OptionsManager.php b/src/Spout/Reader/XLSX/Manager/OptionsManager.php index 7677c22..253e009 100644 --- a/src/Spout/Reader/XLSX/Manager/OptionsManager.php +++ b/src/Spout/Reader/XLSX/Manager/OptionsManager.php @@ -20,6 +20,7 @@ class OptionsManager extends OptionsManagerAbstract Options::TEMP_FOLDER, Options::SHOULD_FORMAT_DATES, Options::SHOULD_PRESERVE_EMPTY_ROWS, + Options::SHOULD_USE_1904_DATES, ]; } @@ -31,5 +32,6 @@ class OptionsManager extends OptionsManagerAbstract $this->setOption(Options::TEMP_FOLDER, sys_get_temp_dir()); $this->setOption(Options::SHOULD_FORMAT_DATES, false); $this->setOption(Options::SHOULD_PRESERVE_EMPTY_ROWS, false); + $this->setOption(Options::SHOULD_USE_1904_DATES, false); } } diff --git a/src/Spout/Reader/XLSX/Manager/SheetManager.php b/src/Spout/Reader/XLSX/Manager/SheetManager.php index a848948..1dbcc95 100644 --- a/src/Spout/Reader/XLSX/Manager/SheetManager.php +++ b/src/Spout/Reader/XLSX/Manager/SheetManager.php @@ -2,6 +2,8 @@ namespace Box\Spout\Reader\XLSX\Manager; +use Box\Spout\Reader\Common\Entity\Options; +use Box\Spout\Reader\Common\XMLProcessor; use Box\Spout\Reader\XLSX\Creator\EntityFactory; use Box\Spout\Reader\XLSX\Sheet; @@ -16,12 +18,14 @@ class SheetManager const WORKBOOK_XML_FILE_PATH = 'xl/workbook.xml'; /** Definition of XML node names used to parse data */ + const XML_NODE_WORKBOOK_PROPERTIES = 'workbookPr'; const XML_NODE_WORKBOOK_VIEW = 'workbookView'; const XML_NODE_SHEET = 'sheet'; const XML_NODE_SHEETS = 'sheets'; const XML_NODE_RELATIONSHIP = 'Relationship'; /** Definition of XML attributes used to parse data */ + const XML_ATTRIBUTE_DATE_1904 = 'date1904'; const XML_ATTRIBUTE_ACTIVE_TAB = 'activeTab'; const XML_ATTRIBUTE_R_ID = 'r:id'; const XML_ATTRIBUTE_NAME = 'name'; @@ -46,6 +50,15 @@ class SheetManager /** @var \Box\Spout\Common\Helper\Escaper\XLSX Used to unescape XML data */ protected $escaper; + /** @var array List of sheets */ + protected $sheets; + + /** @var int Index of the sheet currently read */ + protected $currentSheetIndex; + + /** @var int Index of the active sheet (0 by default) */ + protected $activeSheetIndex; + /** * @param string $filePath Path of the XLSX file being read * @param \Box\Spout\Common\Manager\OptionsManagerInterface $optionsManager Reader's options manager @@ -71,32 +84,70 @@ class SheetManager */ public function getSheets() { - $sheets = []; - $sheetIndex = 0; - $activeSheetIndex = 0; // By default, the first sheet is active + $this->sheets = []; + $this->currentSheetIndex = 0; + $this->activeSheetIndex = 0; // By default, the first sheet is active $xmlReader = $this->entityFactory->createXMLReader(); + $xmlProcessor = $this->entityFactory->createXMLProcessor($xmlReader); + + $xmlProcessor->registerCallback(self::XML_NODE_WORKBOOK_PROPERTIES, XMLProcessor::NODE_TYPE_START, [$this, 'processWorkbookPropertiesStartingNode']); + $xmlProcessor->registerCallback(self::XML_NODE_WORKBOOK_VIEW, XMLProcessor::NODE_TYPE_START, [$this, 'processWorkbookViewStartingNode']); + $xmlProcessor->registerCallback(self::XML_NODE_SHEET, XMLProcessor::NODE_TYPE_START, [$this, 'processSheetStartingNode']); + $xmlProcessor->registerCallback(self::XML_NODE_SHEETS, XMLProcessor::NODE_TYPE_END, [$this, 'processSheetsEndingNode']); if ($xmlReader->openFileInZip($this->filePath, self::WORKBOOK_XML_FILE_PATH)) { - while ($xmlReader->read()) { - if ($xmlReader->isPositionedOnStartingNode(self::XML_NODE_WORKBOOK_VIEW)) { - // The "workbookView" node is located before "sheet" nodes, ensuring that - // the active sheet is known before parsing sheets data. - $activeSheetIndex = (int) $xmlReader->getAttribute(self::XML_ATTRIBUTE_ACTIVE_TAB); - } elseif ($xmlReader->isPositionedOnStartingNode(self::XML_NODE_SHEET)) { - $isSheetActive = ($sheetIndex === $activeSheetIndex); - $sheets[] = $this->getSheetFromSheetXMLNode($xmlReader, $sheetIndex, $isSheetActive); - $sheetIndex++; - } elseif ($xmlReader->isPositionedOnEndingNode(self::XML_NODE_SHEETS)) { - // stop reading once all sheets have been read - break; - } - } - + $xmlProcessor->readUntilStopped(); $xmlReader->close(); } - return $sheets; + return $this->sheets; + } + + /** + * @param \Box\Spout\Reader\Wrapper\XMLReader $xmlReader XMLReader object, positioned on a "" starting node + * @return int A return code that indicates what action should the processor take next + */ + protected function processWorkbookPropertiesStartingNode($xmlReader) + { + $shouldUse1904Dates = (bool) $xmlReader->getAttribute(self::XML_ATTRIBUTE_DATE_1904); + $this->optionsManager->setOption(Options::SHOULD_USE_1904_DATES, $shouldUse1904Dates); + + return XMLProcessor::PROCESSING_CONTINUE; + } + + /** + * @param \Box\Spout\Reader\Wrapper\XMLReader $xmlReader XMLReader object, positioned on a "" starting node + * @return int A return code that indicates what action should the processor take next + */ + protected function processWorkbookViewStartingNode($xmlReader) + { + // The "workbookView" node is located before "sheet" nodes, ensuring that + // the active sheet is known before parsing sheets data. + $this->activeSheetIndex = (int) $xmlReader->getAttribute(self::XML_ATTRIBUTE_ACTIVE_TAB); + + return XMLProcessor::PROCESSING_CONTINUE; + } + + /** + * @param \Box\Spout\Reader\Wrapper\XMLReader $xmlReader XMLReader object, positioned on a "" starting node + * @return int A return code that indicates what action should the processor take next + */ + protected function processSheetStartingNode($xmlReader) + { + $isSheetActive = ($this->currentSheetIndex === $this->activeSheetIndex); + $this->sheets[] = $this->getSheetFromSheetXMLNode($xmlReader, $this->currentSheetIndex, $isSheetActive); + $this->currentSheetIndex++; + + return XMLProcessor::PROCESSING_CONTINUE; + } + + /** + * @return int A return code that indicates what action should the processor take next + */ + protected function processSheetsEndingNode() + { + return XMLProcessor::PROCESSING_STOP; } /** diff --git a/tests/Spout/Reader/XLSX/Helper/CellValueFormatterTest.php b/tests/Spout/Reader/XLSX/Helper/CellValueFormatterTest.php index ef58574..4167cf8 100644 --- a/tests/Spout/Reader/XLSX/Helper/CellValueFormatterTest.php +++ b/tests/Spout/Reader/XLSX/Helper/CellValueFormatterTest.php @@ -15,29 +15,51 @@ class CellValueFormatterTest extends \PHPUnit_Framework_TestCase public function dataProviderForTestExcelDate() { return [ - [CellValueFormatter::CELL_TYPE_NUMERIC, 42429, '2016-02-29 00:00:00'], - [CellValueFormatter::CELL_TYPE_NUMERIC, '146098', '2299-12-31 00:00:00'], - [CellValueFormatter::CELL_TYPE_NUMERIC, -700, null], - [CellValueFormatter::CELL_TYPE_NUMERIC, 0, '1900-01-01 00:00:00'], - [CellValueFormatter::CELL_TYPE_NUMERIC, 0.25, '1900-01-01 06:00:00'], - [CellValueFormatter::CELL_TYPE_NUMERIC, 0.5, '1900-01-01 12:00:00'], - [CellValueFormatter::CELL_TYPE_NUMERIC, 0.75, '1900-01-01 18:00:00'], - [CellValueFormatter::CELL_TYPE_NUMERIC, 0.99999, '1900-01-01 23:59:59'], - [CellValueFormatter::CELL_TYPE_NUMERIC, 1, '1900-01-01 00:00:00'], - [CellValueFormatter::CELL_TYPE_NUMERIC, 59.999988425926, '1900-02-28 23:59:59'], - [CellValueFormatter::CELL_TYPE_NUMERIC, 60.458333333333, '1900-02-28 11:00:00'], + // use 1904 dates, node value, expected date as string + + // 1900 calendar + [false, 3687.4207639, '1910-02-03 10:05:54'], + [false, 2.5000000, '1900-01-01 12:00:00'], + [false, 2958465.9999884, '9999-12-31 23:59:59'], + [false, 2958465.9999885, null], + [false, -2337.999989, '1893-08-05 00:00:01'], + [false, -693593, '0001-01-01 00:00:00'], + [false, -693593.0000001, null], + [false, 0, '1899-12-30 00:00:00'], + [false, 0.25, '1899-12-30 06:00:00'], + [false, 0.5, '1899-12-30 12:00:00'], + [false, 0.75, '1899-12-30 18:00:00'], + [false, 0.99999, '1899-12-30 23:59:59'], + [false, 1, '1899-12-31 00:00:00'], + [false, '3687.4207639', '1910-02-03 10:05:54'], + + // 1904 calendar + [true, 2225.4207639, '1910-02-03 10:05:54'], + [true, 2.5000000, '1904-01-03 12:00:00'], + [true, 2957003.9999884, '9999-12-31 23:59:59'], + [true, 2957003.9999885, null], + [true, -3799.999989, '1893-08-05 00:00:01'], + [true, -695055, '0001-01-01 00:00:00'], + [true, -695055.0000001, null], + [true, 0, '1904-01-01 00:00:00'], + [true, 0.25, '1904-01-01 06:00:00'], + [true, 0.5, '1904-01-01 12:00:00'], + [true, 0.75, '1904-01-01 18:00:00'], + [true, 0.99999, '1904-01-01 23:59:59'], + [true, 1, '1904-01-02 00:00:00'], + [true, '2225.4207639', '1910-02-03 10:05:54'], ]; } /** * @dataProvider dataProviderForTestExcelDate * - * @param string $cellType + * @param bool $shouldUse1904Dates * @param int|float|string $nodeValue * @param string|null $expectedDateAsString * @return void */ - public function testExcelDate($cellType, $nodeValue, $expectedDateAsString) + public function testExcelDate($shouldUse1904Dates, $nodeValue, $expectedDateAsString) { $nodeListMock = $this->getMockBuilder('DOMNodeList')->disableOriginalConstructor()->getMock(); @@ -53,7 +75,7 @@ class CellValueFormatterTest extends \PHPUnit_Framework_TestCase ->expects($this->atLeastOnce()) ->method('getAttribute') ->will($this->returnValueMap([ - [CellValueFormatter::XML_ATTRIBUTE_TYPE, $cellType], + [CellValueFormatter::XML_ATTRIBUTE_TYPE, CellValueFormatter::CELL_TYPE_NUMERIC], [CellValueFormatter::XML_ATTRIBUTE_STYLE_ID, 123], ])); @@ -72,7 +94,7 @@ class CellValueFormatterTest extends \PHPUnit_Framework_TestCase ->with(123) ->will($this->returnValue(true)); - $formatter = new CellValueFormatter(null, $styleManagerMock, false, new Escaper\XLSX()); + $formatter = new CellValueFormatter(null, $styleManagerMock, false, $shouldUse1904Dates, new Escaper\XLSX()); $result = $formatter->extractAndFormatNodeValue($nodeMock); if ($expectedDateAsString === null) { @@ -126,7 +148,7 @@ class CellValueFormatterTest extends \PHPUnit_Framework_TestCase ->method('shouldFormatNumericValueAsDate') ->will($this->returnValue(false)); - $formatter = new CellValueFormatter(null, $styleManagerMock, false, new Escaper\XLSX()); + $formatter = new CellValueFormatter(null, $styleManagerMock, false, false, new Escaper\XLSX()); $formattedValue = \ReflectionHelper::callMethodOnObject($formatter, 'formatNumericCellValue', $value, 0); $this->assertEquals($expectedFormattedValue, $formattedValue); @@ -169,7 +191,7 @@ class CellValueFormatterTest extends \PHPUnit_Framework_TestCase ->with(CellValueFormatter::XML_NODE_INLINE_STRING_VALUE) ->will($this->returnValue($nodeListMock)); - $formatter = new CellValueFormatter(null, null, false, new Escaper\XLSX()); + $formatter = new CellValueFormatter(null, null, false, false, new Escaper\XLSX()); $formattedValue = \ReflectionHelper::callMethodOnObject($formatter, 'formatInlineStringCellValue', $nodeMock); $this->assertEquals($expectedFormattedValue, $formattedValue); diff --git a/tests/Spout/Reader/XLSX/ReaderTest.php b/tests/Spout/Reader/XLSX/ReaderTest.php index e51f2df..00a0ede 100644 --- a/tests/Spout/Reader/XLSX/ReaderTest.php +++ b/tests/Spout/Reader/XLSX/ReaderTest.php @@ -231,9 +231,34 @@ class ReaderTest extends \PHPUnit_Framework_TestCase \DateTime::createFromFormat('Y-m-d H:i:s', '2015-09-01 22:23:00'), ], [ - \DateTime::createFromFormat('Y-m-d H:i:s', '1900-02-28 23:59:59'), + \DateTime::createFromFormat('Y-m-d H:i:s', '1900-02-27 23:59:59'), \DateTime::createFromFormat('Y-m-d H:i:s', '1900-03-01 00:00:00'), - \DateTime::createFromFormat('Y-m-d H:i:s', '1900-02-28 11:00:00'), // 1900-02-29 should be converted to 1900-02-28 + \DateTime::createFromFormat('Y-m-d H:i:s', '1900-02-28 11:00:00'), + ], + ]; + $this->assertEquals($expectedRows, $allRows); + } + + /** + * @return void + */ + public function testReadShouldSupportDifferentDatesAsNumericTimestampWith1904Calendar() + { + // make sure dates are always created with the same timezone + date_default_timezone_set('UTC'); + + $allRows = $this->getAllRowsForFile('sheet_with_different_numeric_value_dates_1904_calendar.xlsx'); + + $expectedRows = [ + [ + \DateTime::createFromFormat('Y-m-d H:i:s', '2019-09-02 00:00:00'), + \DateTime::createFromFormat('Y-m-d H:i:s', '2019-09-03 00:00:00'), + \DateTime::createFromFormat('Y-m-d H:i:s', '2019-09-02 22:23:00'), + ], + [ + \DateTime::createFromFormat('Y-m-d H:i:s', '1904-02-29 23:59:59'), + \DateTime::createFromFormat('Y-m-d H:i:s', '1904-03-02 00:00:00'), + \DateTime::createFromFormat('Y-m-d H:i:s', '1904-03-01 11:00:00'), ], ]; $this->assertEquals($expectedRows, $allRows); @@ -251,11 +276,11 @@ class ReaderTest extends \PHPUnit_Framework_TestCase $expectedRows = [ [ - \DateTime::createFromFormat('Y-m-d H:i:s', '1900-01-01 00:00:00'), - \DateTime::createFromFormat('Y-m-d H:i:s', '1900-01-01 11:29:00'), - \DateTime::createFromFormat('Y-m-d H:i:s', '1900-01-01 23:29:00'), - \DateTime::createFromFormat('Y-m-d H:i:s', '1900-01-01 01:42:25'), - \DateTime::createFromFormat('Y-m-d H:i:s', '1900-01-01 13:42:25'), + \DateTime::createFromFormat('Y-m-d H:i:s', '1899-12-30 00:00:00'), + \DateTime::createFromFormat('Y-m-d H:i:s', '1899-12-30 11:29:00'), + \DateTime::createFromFormat('Y-m-d H:i:s', '1899-12-30 23:29:00'), + \DateTime::createFromFormat('Y-m-d H:i:s', '1899-12-30 01:42:25'), + \DateTime::createFromFormat('Y-m-d H:i:s', '1899-12-30 13:42:25'), ], ]; $this->assertEquals($expectedRows, $allRows); diff --git a/tests/resources/xlsx/sheet_with_different_numeric_value_dates_1904_calendar.xlsx b/tests/resources/xlsx/sheet_with_different_numeric_value_dates_1904_calendar.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..e496ef0cf5bd6be5c141db50851c2be3b79eb230 GIT binary patch literal 22508 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 rN